You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan 034 (specs/034-opendox-standalone-operation/tasks.md, read at openxFactory main6b97c601), phase-2 slice P2-V, its one task: T057 (#1144's 7.1, 7.1a, 7.1b and 7.2, with 7.1 as T007's batch G amends it). Claimed on openxFactory#656 in 5873510118.
Authored ahead, as a phase-2 draft (Brett 2026-09-27: "Start phase 2's independent tasks"). T049 has landed (openxFactory#1204 → 9d2e5bc3), so phase 2 is open. Both steps it waited on are done:
This PR was stacked on #57 (T054), and is now based on main: it was retargeted before #57 landed, and #57 landed as a691e4e4. The diff below is T057's alone. #57's head 8e7da4a2 was merged here as 27bcefc, after T049 landed. #57 has since merged mainfa8862cc as 1a603677, which has 8e7da4a2's tree.
T057 needs the seam: the validator's neutral kind is held to T052's generator_seam.NEUTRAL_SNAPSHOT_KIND, and the in-process checks run openDox's own projection over T050's and T051's fixtures.
src/opendox/contracts/: the input set (7.1, 7.1b). It holds the four packaged copies, which are openDox's own spec leg's four: ideation-workbench, opendox-snapshot, xfactory-workbench-chat-turn and xfactory-workbench-model-catalog.
Each copy is byte for byte openDox-spec's file at f7ee3c76, T053 as landed and the spec commit the openDox root pins. That commit carries all four. It has the tree of § 3.4 S6: /source becomes openDox's own fixed core arm (RULED Q4) #16's last head, cd49eb25, where the copies were first taken. Its three older files are the blobs of the root's previous spec pin, 8fe8c4c7.
copies.yaml is the record: the spec-leg commit, and each copy's path and sha256. A copy is read only after its sha256 is proved against the record. A changed, absent or unpinned copy is refused before a byte of it is parsed (neutral-product-pin's rule for a vendored contract).
gate-intent and ideation-possibles-register are not in the set, and neither are the other four schemas of the family. A record that names any copy other than the four, or leaves one out, is refused (COPY_IDS), so no edit to the record lets one in.
The record and the four copies ship as package data, under a new opendox.contracts key in pyproject.toml (below).
src/opendox/validator.py: the validator, new surface at the code leg (7.2).
It evaluates JSON Schema 2020-12, exactly the keywords the four copies use. format: date-time is asserted, as the consumer's validator asserts it.
A copy that uses a keyword, format, dialect or reference it does not evaluate is refused when its validator is built, never evaluated with that keyword left out.
So is a keyword holding a value of another shape, an embedded resource, a subschema that contains itself, and a reference cycle that never moves into the instance. A malformed copy is refused as unavailable, never a crash (below). An instance whose keys are not text, which YAML allows, is judged and never crashed on, and so is a value of any size or depth.
A refusal names its rule: [<x-rule>] <where>: <detail>.
It implements the neutral snapshot's seven reference rules. A snapshot validator is refused if the contract's catalog and the implemented rules differ.
validators() answers one validator per wire kind, with the jsonschema-shaped iter_errors() that the doxBench seam's readers use, built as openxFactory's doxbench_contracts builds its own (T085).
Its docstring records why the consumer's script cannot be reused (7.1a), measured at openXdox-code 4610bca5 (below).
tests/test_validator_input_set.py is the falsifier (28 cases). tests/test_validator.py tests the evaluator (185 cases).
tests/fixtures/spec-examples/ is its corpus, openDox-spec's own examples at cd49eb25. It holds 13 positives of three kinds (2 opendox-snapshot, 5 chat-turn and 6 model-catalog) and the neutral contract's 32 negatives, one per rule. All 45 are verified as the spec leg's exact blobs.
The spec leg's two ideation-workbench examples are not carried. This leg's committed-manifest guard (workbench.committed_manifests, held by tests/test_workbench.py::test_no_workbench_manifest_is_tracked_in_this_repo) refuses a workbench manifest tracked anywhere but the root's examples/ and contracts/schemas/. CI went red on them at 97b314a (run 36450791192), and 69f2040 removes them.
The kind is held instead over the manifests openDox's own workbench.Workbench writes: one per seed kind, each with every member route, an exclusion, every action and a notebook binding. An override with no recorded reason is refused as [required] at its member. The cross-check below still covers the two examples, read from the spec leg's own tree.
No skip is added, since EXPECT_SKIPPED is exactly 11. It touches no openspec/changes/ path, no conftest.py, no workflow, no README.md and no pin. Its one pyproject.toml change is the package-data key, on the holder's decision (below).
The falsifier
T057's falsifier is "the packaged-copy digest test and the 7.1b test". At 80b5f9e:
Both go red when they should. In a scratch clone of ca52182 I changed one byte of the neutral copy (byte 10181, i to I) and planted a gate-intent copy beside the four:
E AssertionError: src/opendox/contracts/schemas/opendox-snapshot.schema.yaml is b9f3074a6d7d7670289e2a6d048169550a8571820dd8dba72acf4cd52ee9b72b, and the record pins f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a. A copy is never edited in place
E AssertionError: a copy of gate-intent is carried under src/opendox/contracts/schemas/, which 7.1b refuses: requirement 1 keeps it with openxFactory
FAILED tests/test_validator_input_set.py::test_each_packaged_copy_is_the_spec_legs_file_at_the_pinned_commit
FAILED tests/test_validator_input_set.py::test_gate_intent_and_the_possibles_register_are_not_in_the_set
2 failed in 0.46s
The two tests' assertions that went red there are unchanged since. 9d2cda1 adds one assertion to the 7.1b test: COPY_IDS names neither schema.
The digest test holds each copy to the spec-leg commit the openDox root pins. The test states the root's digests apart from the record, so a copy and its recorded digest cannot move together unseen.
The three older ones are unchanged from the previous spec pin, 8fe8c4c7 (the root's main at 663ac683).
Read with git show <commit>:contracts/schemas/<file> | sha256sum in openDox-spec:
copy
sha256 at 8fe8c4c7, cd49eb25 and f7ee3c76
the root's manifest at 52005213
ideation-workbench
d30438491119…faafc
same
xfactory-workbench-chat-turn
350bfedc0269…1dc1d
same
xfactory-workbench-model-catalog
e563cc9fc6ed…21635
same
opendox-snapshot
absent at 8fe8c4c7; f9e3e111af1d…4a584a at cd49eb25 and f7ee3c76
same
The package-data line (on the holder's decision)
7.1 ships the four as package data. pyproject.toml used to package only web/**. On the holder's decision, this PR now carries the new key: openDox-code's phase-1 work has landed, so no phase-1 writer edits pyproject.toml any more. It is its own key beside the bundle's line, which test_gate_loop_contributed holds verbatim:
test_the_package_data_ships_the_record_and_every_copy lands with it. It holds the table to the record: the patterns under opendox.contracts ship exactly the record and every copy the record pins. At ca52182, where the line landed (the test is unchanged since):
It passes.
With only the key line removed, it fails (KeyError: 'opendox.contracts').
With a stray schema planted under schemas/, it fails, because the patterns would ship a file the record does not pin.
The test parses pyproject.toml and builds no wheel. The install-level evidence is real wheels: each built with pip wheel . --no-deps from a clean clone, installed into a separate venv and probed from outside any source tree. The two module digests are the head's own files. The probe's opendox from: lines are left out; each showed the run importing from its venv's site-packages.
== wheel built from 80b5f9e
opendox/contracts/__init__.py sha256 5b85edfe427e
opendox/contracts/copies.yaml
opendox/contracts/schemas/ideation-workbench.schema.yaml
opendox/contracts/schemas/opendox-snapshot.schema.yaml
opendox/contracts/schemas/xfactory-workbench-chat-turn.schema.yaml
opendox/contracts/schemas/xfactory-workbench-model-catalog.schema.yaml
opendox/validator.py sha256 9e6fc2974398
kinds: ['ideation-workbench', 'opendox-snapshot', 'workbench-chat-turn-v2', 'workbench-chat-turn-v2-failure', 'workbench-chat-turn-v2-success', 'workbench-model-catalog']
six-stations: no violation
title negative: ["[title-and-summary-are-text] /documents/2/title: '' is shorter than 1"]
rc=0
Compared with a wheel built without the line, it adds exactly those five data files and removes nothing: 106 entries outside dist-info become 111, and the 41 opendox/web/ entries are unchanged. Without the line, an installed validator refused. That was measured at 69f2040, before the line landed:
== wheel without the held line
opendox/contracts/__init__.py sha256 b264a03b6f5b
opendox/validator.py sha256 f620194b0b4f
UNAVAILABLE: opendox.contracts has no copies.yaml: the package was built or installed without it (FileNotFoundError)
rc=3
Copilot's two findings, at 97b314a and again at 69f2040, were this line. Both threads were answered twice, with the held measurement and then with ca52182, and both are resolved.
The fail-closed build (Copilot's findings at ca52182, bc3470b, 2b32cbf, 9d2cda1 and 4474514)
The module promises that a copy it cannot evaluate is refused when its validator is built, as SchemaNotEvaluable (a ValidatorUnavailable). Copilot's review at ca52182 found a hole in that promise, in code unchanged since d89f252a: type: {} made set(names) raise TypeError during the build. I probed the same class with 40 malformed values. At ca52182:
5 crashed the build: type: {}, type: [["string"]], format: {}, allOf: 5, and two non-text unknown keys (a mixed sort). A pattern with an unbounded repetition crashed it too (OverflowError).
30 built. Of those, 20 then crashed on an instance. The other 10 judged instances by a value draft 2020-12 does not allow: uniqueItems: "yes" read as true, maxLength: -1 refused every string, and maximum: nan passed everything.
5 were refused.
bc3470b's commit message says twenty-three of them built. The probe's count is thirty.
At 80b5f9e all 40 are refused, and so are these:
Every evaluated keyword's value is checked against the shape draft 2020-12's metaschema gives it (_SHAPES). A test holds _SHAPES to KEYWORDS, so no evaluated keyword goes unchecked.
Every reference's target, and the kind's entry, is walked like the document. A reference can reach a node no walk of the subschemas passes, such as one inside an enum.
An embedded resource ($id or $schema below the root) is refused, since it would move where its references resolve.
A subschema that contains itself, which a YAML alias can build, is refused where it loops.
A JSON-pointer index that is not a plain decimal (-1, 01) names nothing. Python's int() would read another element.
From Copilot's review at bc3470b: a reference cycle that never moves into the instance, such as $ref: "#" or two $defs that refer to each other, is refused, naming the cycle. jsonschema 4.26.0 recurses on those until Python's limit. A recursive schema that moves into the instance before it recurs, such as a tree whose children are items, is still evaluated. A copy nested deeper than Python's recursion limit is refused too.
From Copilot's review at 2b32cbf: a JSON-pointer token whose ~ is not ~0 or ~1 makes no pointer (RFC 6901). A $ref carrying % is refused as a percent-encoded fragment this module does not decode. At 2b32cbf, #/$defs/a%20b read the literal key a%20b and passed 5, where jsonschema decodes it to a b and fails 5. The same review's record finding is under the input set above.
From Copilot's review at 9d2cda1: the record's key refusal ran sorted() over a record's keys, so a key that is not text raised TypeError. It now orders keys by their repr. An instance fuzz of the same class found worse at evaluation: under additionalProperties: false, the report ran sorted() over an instance's extra keys, and 115 of 3,000 odd instances crashed a validator. Those keys are ordered by repr too. YAML nested past Python's recursion limit escaped the three YAML reads (the record's, contracts.load() and validator_for()) as RecursionError, and each now refuses it.
From Copilot's review at 4474514: a bound past a float's range, such as a 400-digit YAML integer, crashed the build in math.isfinite(), and every int is now finite. A const or enum that contains itself is refused. On the instance side, equality was judged on nested tuples, which CPython compares recursively, so an instance value 5000 deep crashed its judgement. The canon is now text, built without recursing and compared as text, and it keeps JSON equality. A violation's detail always shows something, and PyYAML's ValueError (an integer past 4300 digits, an impossible date) is refused by all three YAML reads.
bc3470b's review also found that enum: [] and required: [] should be refused. Those two findings are not taken. Both are valid 2020-12 schemas. The metaschema gives enum no minItems and gives requireddefault: []; the minItems: 1 was draft-04's. jsonschema treats both as this validator does, and their threads carry the evidence.
The four copies use none of the refused forms: they have no anchors or aliases, no nested $id or $schema, only integer counts, and references only into $defs. All six kinds build as before. Of the 58 new cases through 2b32cbf, 57 fail against ca52182's validator and pass here. The 58th, the recursive tree, passes on both, as the guard against over-refusing. 9d2cda1's four new refusal cases fail against 2b32cbf's code, and its escape case passes on both, as the same kind of guard. 4474514's four new cases fail against 9d2cda1's code. 80b5f9e's six new refusal and depth cases fail against 4474514's code. Its canon-equality case passes on both, as the guard that JSON equality is unchanged.
After T049: Copilot at 27bcefc0, one thread, taken in bf51a30a
r4136329332: an instance deeper than the walk raised RecursionError. A recursive schema that moves into the instance (a tree whose children are items of the node) recurs once per level of the instance, and the build accepts such a schema. Measured at 27bcefc0 with this module's own tree schema, at the default limit of 1000: 100 levels judged, and 200, 300, 400, 1000 and 5000 levels raised.
Now judged, not crashed on.KindValidator.iter_errors() catches the RecursionError from the walk, once the walk's frames have unwound, and yields one violation, [evaluation-depth] <root>: …. Its rule, DEPTH_RULE, is exported. So the instance is never valid, which fails closed.
What the walk found before the limit stands, and the reference rules still run.
violations(), is_valid() and validate() all go through it.
The evaluator stays recursive, because rewriting it on an explicit stack would put the 0-disagreement cross-check at risk. A tree the walk reaches is judged as before.
The regression case is test_an_instance_deeper_than_the_walk_is_judged_not_crashed_on.
A 50-level tree is valid.
A 5000-level one yields exactly the depth violation, at the root.
With a node near the top broken, both ("required", "/children/0") and the depth violation are named.
Against 27bcefc0's validator the case raises RecursionError. A mutant that swallows the error silently fails it too.
The whole suite at bf51a30a:selected=2812 passed=2801 skipped=11, which is +1. F4.1 reads 19.
Copilot at bf51a30a ("Needs a closer look", no thread): taken in 6b68e32a
"Previously missed": a bound of any size crashed the violation's detail._is_bound() admits an integer of any size, so a schema with minimum: 10**5000 builds. But eight details (minimum, maximum, minLength, maxLength, minItems, maxItems, minProperties, maxProperties) put the bound into the text directly. So validating 0 against that schema raised ValueError, Python's 4300-digit limit on int-to-text, while the violation was being written.
Reproduced for minimum, maximum (at -10**5000), minLength, minItems and minProperties. The three max counts cannot fire at such a size.
Now each bound is shown through _brief(), as the instance's value already was, so a huge one reads <an int of 16610 bits>. An ordinary bound reads exactly as before, since _brief(5) is "5".
The regression case is test_a_violations_detail_shows_a_bound_of_any_size, five cases, which all fail against bf51a30a's validator, plus an ordinary-bound check.
CI at 6b68e32a:validate and SonarCloud are green, with selected=2817 passed=2806 skipped=11 failures=0 errors=0, which is +5.
Copilot at 6b68e32a: "Needs a closer look", no findings, 0 threads
The overview's one line reads "Require audit_ref whenever the provider_retry object is present." It is not taken, on the holder's ruling (2026-09-29), and answered in this comment. The note opened no thread.
provider_retry is in xfactory-workbench-chat-turn.schema.yaml, one of the four packaged copies. That copy must stay byte for byte the spec leg's file, now the file at f7ee3c76. Its sha256 (350bfedc…) is the one copies.yaml and the root's manifest record. Requiring audit_ref in the copy alone would break the falsifier this PR exists for.
audit_ref is optional by the contract (required: [retried, at_most_once]), and serve_wire.py writes it only when it has one. Making it required is a contract change for the spec leg and its owner, not for T057.
e94ab323 moves copies.yaml's commit and the falsifier's SPEC_COMMIT from cd49eb25 to f7ee3c76, together, on the holder's word. No digest moves. Each of the four copies is cmp-identical to the file at f7ee3c76. The comments that say what the root pins now say it, and tests/test_validator.py's corpus note names the landed commit.
Moving SPEC_COMMIT back alone fails 2 of the falsifier's 28 cases, so the two still move together or not at all.
The whole suite at e94ab323: selected=2817 passed=2806 skipped=11, unchanged.
After #57 landed: 1433234, cb40b977 and three review rounds
1433234 merges T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's final head d7954fc6, with no conflict. T057's own diff against it is byte-identical to its diff against 8e7da4a2: 55 files, +6320. cb40b977 merges maina691e4e4, whose tree is d7954fc6's, so it adds no content.
Copilot at cb40b977: 2 threads.
r4139739110, taken in 3351f6a7: YAML builds values JSON has not (sets, bytes, dates, non-text keys, non-finite numbers), and const/enum were checked only for self-containment. _first_non_json() now refuses them at build. test_a_const_or_enum_that_holds_a_value_json_has_not_is_refused, 6 cases, red against cb40b977.
r4139739167, not taken: four documents in the six-stations example carry water, so its count of 4 is right, the validator finds no violation there, and the file is byte for byte openDox-spec's at f7ee3c76.
The T058 writer's measurement (openDox-code#68, r4139734412), taken in 2b8ad245: a packaged record or copy that is present but unreadable raised PermissionError out of validator_for(). Every OSError in _read_package_file is now a CopyRefused, and a missing file keeps its message. test_a_present_file_that_cannot_be_read_is_refused_not_raised, over the record and the snapshot copy at mode 000 (skipped as root), red against 3351f6a7.
Copilot at 2b8ad245: 3 threads.
r4139823704 and r4139823779, taken in 753ffa19: an instance key of 10**5000 has no decimal text, so a pointer beneath it, and the unexpected-properties report, raised ValueError. Pointer parts fall back to _shown, and extra keys are ordered and shown item by item (_brief_items). test_an_instance_key_of_any_size_is_named_not_crashed_on, red against 2b8ad245.
r4139823750, not taken: a tuple key canonicalizes as an opaque value, and enum and uniqueItems judge it without crashing. test_a_non_text_key_that_is_not_a_scalar_is_judged_as_itself guards it.
Copilot at 753ffa19: "Needs a closer look", no findings, 0 threads. CI is green, with selected=2850 passed=2839 skipped=11 failures=0 errors=0.
7.1a, measured
The consumer's scripts/validate-ideation-dashboard-contracts.py, run at openXdox-code 4610bca5:
== no CONTRACTS_DIR, one openDox snapshot example
ERROR harness failure: .../contracts/schemas carries none of the family's 10 schemas (this tree carries no contracts/ and CONTRACTS_DIR is not set; export it as the spec leg's contracts directory), so nothing can be validated and no mode may report success
rc=2
== CONTRACTS_DIR = openDox-spec's contracts (all four there), the same example
ERROR [kind] .../opendox-snapshot-six-stations.example.yaml: unrecognized document (no known kind, no 'possibles_register' section)
rc=1
The docstring's six reasons are: it finds its schemas from its own position; it names ten schemas of three owners; it is the consumer's file; it needs jsonschema, referencing and rfc3339-validator; it knows no opendox-snapshot; and it is a subprocess script, where openDox-code has no scripts/.
Verification
CI at 80b5f9e: validate (run 36471401716) and SonarCloud are green.
The case-by-case junit diff shows +213 added (185 and 28), all passed and all in the two new files. It shows 0 removed and 0 changed outcomes, and the same 11 skips.
--noconftest passes too (213).
CI was red at 97b314a on one case: the committed-manifest guard, over the two workbench examples then carried (above). My earlier local whole-suite run had passed because it ran before those files were committed, so these runs are at the committed head.
LANG=C.UTF-8 is set because CI's runner sets it. In a shell with no locale, test_model_provider_broker::test_the_broker_child_inherits_no_credential_shaped_environment fails at ca52182 and here alike, because Python's C-locale coercion (PEP 538) puts LC_CTYPE in the broker child's environment. That is the shell, not this PR.
The cross-check, both fuzzes, the scan and the mutation check below all ran on 80b5f9e's committed source.
Against jsonschema and the spec leg's own evaluator, over every one of openDox-spec's 86 examples at cd49eb25, all kinds, positives and negatives: 0 disagreements.
jsonschema 4.26.0 ran with its format checker, per kind as openxFactory builds its validators.
For the neutral kind, the rules found equal the spec leg's tests/test_opendox_snapshot_contract.py evaluator's (rule, place) exactly, and equal jsonschema's read through error.schema's x-rule.
The two examples whose kind is not openDox's raise UnknownKind.
Mutation fuzz: 12,000 seeded random edits of the positive examples, compared as (keyword, place) against jsonschema, gave 0 disagreements beyond one deliberate divergence, met 26 times. rfc3339-validator anchors with $, so it admits ...Z followed by a newline, and this validator refuses it. That divergence is documented in _is_date_time, and the snapshot contract's own patterns guard the same tail.
Instance fuzz: 6,000 instances over two seeds, each a positive example with an odd value planted somewhere. The values are non-text keys, sets, timestamps, bytes, NaN and infinity, lists 5000 deep, self-containing lists and mappings, and 5000-digit ints. All six validators gave 0 crashes, where 115 of the first seed's 3,000 crashed at 9d2cda1.
Mutation check: 77 mutations of the two new modules, each killed by the new tests (77/77), with the sources restored and verified by sha256. One mutant, the cycle search blind to its own trail, loops allocating. The run caps each mutant at 4 GiB, and that one failed there.
F4.1's deferred-reach scan: 19 at the base and 19 here, with the same list. The two new modules reach nothing. With every sibling blocked, the validator imports and validates, and it adds no third-party module beyond PyYAML.
For the later tasks
T058 (the post-render validator in the generate verbs):
call opendox.validator.validate(snapshot, kind=<the generator's declared contract>), and print validator.report(...) on stderr;
ValidatorUnavailable is the "could not run" case, which --strict makes fatal;
tests/test_neutral_projection.py should read this packaged copy (opendox.contracts) in place of T054's tests/fixtures/opendox-snapshot.schema.yaml.
T055 (validate_manifest for ideation-workbench):
This validator checks the manifest against its schema. It does not check the old script's two workbench rules (pinned ⊆ checked, the schema's own VALIDATOR RULE comment; new_candidates disjoint from members ∪ excluded), nor its committed-manifest guard. So routing validate_manifest here drops those rules unless T055 carries them.
test_every_manifest_openDox_own_workbench_writes_validates holds the manifests workbench.Workbench writes to pass here: every seed kind, every member route and every action, with an exclusion and a notebook binding.
T085 (doxBench defaults): register opendox.validator.validators at serve_wire.register_doxbench_validators. The semantic rules of the two wire kinds are still T085's.
T061: nothing from T057 is needed. The consumer's three schemas stay out of this set.
The openDox root:
its contracts/manifest.yaml says the code leg "carries no contracts/ path at all", which is no longer true of src/opendox/contracts/;
nothing at the root holds these copies to its spec pin, so T062's make pins could gain that check.
The holder's answer (2026-09-29) stood throughout: T057's falsifier says "the spec-leg commit the openDox root pins", so the record moved only once the root pinned T053's landed commit.
…an 034)
tests/fixtures/plain-documents/ carries eight .md documents: three sources
(no `stage:` header, two sharing the phrase "rain barrel" so a future
topic-based grouping pass has a pair to find) and one document each
declaring `stage: grouping`, `stage: candidate`, `stage: selection`,
`stage: submission` and `stage: completion` (RULED R1Q13 (a) with (c),
openxFactory#656 comment 5850003126). Every document carries the small
neutral field set the default adapter will require regardless of station
(`title`, `summary`).
tests/test_plain_documents_fixture.py is the vocabulary test T050 names as
its falsifier: it asserts the fixture carries none of the eight controlled
`Status:` words or the change/spec/delta nouns, that it covers all six
stations with exactly one document per explicit station, and that at
least two (but not all) sources share the topic fixture code will need to
group on.
Not yet wired into .github/workflows/validate.yml's explicit pytest list:
phase2-ahead scope keeps this PR out of that file, the pin files and
conftest.py/pyproject.toml/README.md, which the phase-1 chain still edits.
This suite runs by node id today.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erator (plan 034)
Box 5.4 of openxFactory's add-neutral-product-standalone-operability:
"DECLARE THE GENERATOR SEAM - it does not exist and CorpusAdapter is not
it." This declares it, in src/opendox/generator_seam.py, beside
domain_profile.register():
- the operation handed over: generate(repo_root, repository, *,
source_revision=None, generated_at=None, **inputs) -> the snapshot;
- the registration point: register(<SnapshotGenerator>) for a host,
and register_default(...) for an entry point, which registers only
where nothing is registered;
- the conformance a contributed generator must meet. It declares the
contract it writes and every further input it reads, its operation
takes the seam's call, and it answers a snapshot whose kind is that
contract and whose schema_version is an integer. The seam checks the
declaration when it is made and each snapshot when it comes back,
and it refuses, never drops, an undeclared input.
The conformance clause names T053's neutral snapshot kind,
"opendox-snapshot" (openDox-spec#16), for openDox's own generator:
src/opendox/default_generator.py's GENERATOR. The entry points
(cli.build_parser, cli.main, serve.build_server, serve.main) register
it where no host has (R1Q10 (a), openxFactory#656 comment 5850003126,
in R1Q3 (a)'s pattern). A bare process still refuses, naming the seam
and the call. A host replaces the default only before a snapshot has
been generated from it. CorpusAdapter stays closed at six members.
openDox's own generator refuses until T054 builds its projection. Plan
034 orders T052 before T054, and T054 edits neither cli.py nor serve.py
(their single-writer order runs T052, then T055). No verb reaches the
seam before T055 routes the generate verbs, which comes after T054.
Falsifier: the seam tests, tests/test_generator_seam.py (59 cases).
F5.2 is quoted by T059 and T061.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nce it answers
Copilot's review of this PR at 834f8ea raised two findings. Both are
addressed here.
1. A declared input the operation cannot do without was accepted when it
was declared. SnapshotGenerator bound the seam's call with every
declared input given. But generate() passes an input only when its
caller has a value for it, so such an operation failed with a raw
TypeError the first time the option was unset. The declaration now
binds the call twice, once with every declared input given and once
with none given, and every call the seam can make lies between the
two. Conformance clause 2 now says that each declared input is
optional.
2. The default's window closed before its operation ran. generate()
recorded a generation from the entry point's default before the call.
So a generation that failed shut every host out although it wrote
nothing, and that included openDox's own refusal before T054. The
record is now made once a conformant snapshot comes back. A
generation that fails, or whose answer the seam refuses, records
nothing. While a generation from the default is under way, a host's
registration is refused, because that snapshot would come back after
the swap. The early record gave that property. A count of the
generations under way now holds it.
The registry's bookkeeping moves under one threading.Lock, because
serve.py's ThreadingHTTPServer answers each request on a thread of its
own. generate() holds the lock only to resolve and mark, and to record
the end, and never across the generator's call. So a generator may
register, unregister or generate from inside its own call.
Tests: 59 -> 66 cases. Against 834f8ea's seam logic, 8 of the 66 go
red: the new declared-input case, the pre-T054 default case, the two
wrote-nothing cases, the three under-way cases and the re-entrant case.
The concurrency and re-entrancy cases run in a process of their own with
a time limit, so a lock held across a call fails them rather than
hanging the suite. 13 of 13 mutations are caught.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (plan 034)
tests/fixtures/malformed/ carries two .md documents: notes-bee-boxes.md is
an ordinary valid source (non-empty title and summary), and
notes-empty-title.md declares `title:` with nothing after the colon --
present in the header, but an empty string once parsed -- while its
`summary:` stays ordinary. That is exactly one violation of the neutral
snapshot schema T053 adds (opensoft/openDox-spec#16 at cd49eb25):
`documents[].title` and `.summary` are each `type: ["string", "null"],
minLength: 1` (x-rule `title-and-summary-are-text`, "each non-empty text,
or null"). A document that declares no title/summary line at all yields
null, which is valid (openDox-spec's own no-front-matter example); an
empty string is the one value that is neither null nor non-empty text.
Holder decision: T054 copies title/summary verbatim, without coercing an
empty declared value to null or excluding the document, so the violation
survives unchanged into the generated snapshot.
tests/fixtures/malformed/EXPECTED_RULE holds the violated rule's
identifier verbatim, `title-and-summary-are-text`, for the future
`opendox generate --strict` to name (spec.md AT-R1 scenario 2).
T051 carries no falsifier of its own (tasks.md: "used by F7.2"); this PR
adds only the fixture and the sentinel file.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T054's branch stacks on T052's (openDox-code#54, build/034-p2g-t052-generator-seam
at 4ca45d2), which was cut from main 80acead. main has since landed T036 (the
required check runs the whole suite) and T037 (the margin), so this takes main
at 2d11641, and the PR is measured against the whole-suite check it will be
judged by. No conflict: T052's five files and main's changes are disjoint.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…9560ee) into T054's branch
T054's falsifier runs the neutral projection over T050's
tests/fixtures/plain-documents, so this branch carries that fixture until
#53 lands on main. The merge adds only #53's files. Once #53 lands, they
drop out of this branch's diff against main.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… into T054's branch
The holder ruled that T054 copies title and summary without coercing or
excluding them, so T051's malformed fixture keeps its one
title-and-summary-are-text violation in the snapshot. T054 proves that
over T051's tests/fixtures/malformed, so this branch carries that fixture
until #56 lands on main. The merge adds only #56's three files.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…usAdapter (plan 034)
A checkpoint commit, pushed on the coordinator's usage stop. It is not
the finished task: four node-harness cases in tests/test_display_facet.py
still carry the governed snapshot values in their fixtures and fail
against the new defaults, and T054's own test file is not written yet.
What this commit holds:
- src/opendox/neutral_projection.py (new): the projection. It writes
T053's opendox-snapshot, places a document by its neutral stage: key
(reading a value outside the six role keys as a source and reporting
it), copies title and summary verbatim, and applies the topic rule and
the group rule its docstring names.
- src/opendox/default_generator.py: generate() projects the home corpus
instead of refusing. NeutralProjectionNotBuilt is retired, and so is
its refusal case in tests/test_generator_seam.py.
- src/opendox/display_profile.py and web/views/display.js:
SNAPSHOT_VALUES' defaults become the neutral snapshot's values.
display.js keeps its line count, so the web census row is unchanged.
- src/opendox/runtime/local_git_adapter.py: leading_header() is public,
NEUTRAL_FIELDS is declared, WorkingTreeCorpus defaults required_fields
to it, and the suffix branch of classify reports missing fields when
fields are required.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ay-facet cases (plan 034)
This finishes the work 25fe752 checkpointed.
The holder ruled on two of T054's open questions, relayed by the
coordinator:
- An entry the adapter cannot classify (a Makefile, an image) is not a
document. The projection now leaves it out unread, and nothing mentions
it.
- The wheel's grouping tile counts a group's edges, and the neutral schema
is not widened with a tally. views/wheel-model.js now counts
document_edges where a group carries no tallies.document_links, and
uses the tally where one exists, as the governed snapshot's groups do.
The file keeps its 1358 lines, so its census row is unchanged.
tests/test_neutral_projection.py (new) holds T054's falsifier:
- in a fresh process with every sibling blocked, the CLI entry point's
defaults generate over T050's fixture through the seam, and the result
validates against T053's schema with no F5.3 word;
- the topic rule groups a copy of AT-R1's repository (b), which has no
front matter;
- a stage: value outside the six is reported, naming the document, the
value and the six keys, and is read as a source.
Around them it tests:
- the holder's T051 rule (the malformed fixture breaks only its
EXPECTED_RULE);
- what a document is, the stations, and the anchors;
- the default adapter's field set through the entry point;
- the neutral display values in Python and in display.js, and the
wheel's edge count;
- that the projection makes no reach.
The schema is a byte-identical copy of openDox-spec#16's at cd49eb25,
held to its sha256, until T057 ships the packaged one.
tests/test_display_facet.py: four node-harness cases carried the
governed snapshot values in their fixtures. Each now reads the value
openDox ships (SNAPSHOT_VALUES), and the property each one tests is
unchanged. The canvas case's neutral assertion no longer compares
against a value the neutral install can never write. It now asserts
openDox's own word.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(plan 034)
validate failed at 0f42f67 in one case,
test_the_anchors_come_from_the_source_revision_and_the_bytes_repeat.
CI's git prints `%cI` for a zero offset as `2026-09-27T12:00:00Z`, and
the git this was written against (2.43.0) prints
`2026-09-27T12:00:00+00:00`. The projection was right: it records the
stamp exactly as git gives it, and the neutral schema admits both
spellings. The test compared that stamp against the fixture's date as a
string. It now compares the two as instants. The case's first assertion
still holds generated_at to the checkout's own `git show` output, so a
date read from anywhere else is still caught.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…what is true now (plan 034)
Copilot's review of 0f42f67 left three threads.
- r4117298988 (neutral_projection.py): a `topics:` header that listed
nothing fell back to the derived topics, which contradicted the rule
the module's own docstring states. The header is now a declaration
even when it is empty. The document carries no topic, nothing is
derived for it, and it joins no group. That follows the holder's
literal-copy principle for title and summary. New case:
test_an_empty_topics_header_declares_no_topic. With the old rule
restored, it fails.
- r4117299013 and r4117299033 (tests/test_plain_documents_fixture.py,
T050's file, merged here from #53): its docstring said T052 and T054
had not landed. It also said the suite was run by node id outside
validate's explicit list. T054 makes the first false. The second has
been false since T036 (#52), when validate began running the whole
suite. Both paragraphs, and the two phrases beside them ("a future
topic-based grouping pass", "will require"), now say what is true.
Only the docstring changes.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…snapshot (plan 034)
Copilot's review of c0747e9 raised two points.
- Its overview said digit-adjacent topic names were tokenized wrongly,
and they were. The docstring defines a word as a run of three or more
letters, but the tokenizer took letter-and-digit runs and then dropped
any run holding a digit, so `Q3planning` lost `planning`. Letter runs
and digit runs are now separate tokens. New case:
test_a_word_is_a_run_of_letters_even_beside_digits. With the old
tokenizer restored, it fails.
- r4117331489 asks that a supplied source_revision be passed into the
CorpusRef. That is not taken, and the answer is on the product's own
contract:
- the seam defines source_revision as the source anchor to pin, and
the CLI's help for --source-revision says "pin the source_revision
anchor";
- openXdox's governed generator records a supplied revision and scans
the tree it is handed;
- the holder ruled for T054 that content comes from the working tree,
per Brett's T022 ruling.
default_generator's docstring now says so in a paragraph of its own.
Only the docstring changes for it.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T052's branch was cut from main 80acead. main has since landed T036 (the
required check runs the whole suite) and T037 (the margin). This takes
main at 2d11641, so the PR is measured against the whole-suite check it
will be judged by. There is no conflict: T052's five files and main's
changes are disjoint.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot's overview of openDox-code#57 at c0747e9 said that the
generator seam's "replacement-generator tracking can reject valid
registrations". It opened no thread. The claim is real, and there are
two cases, both reproduced at 4ca45d2:
1. The count of generations under way was global, not tied to the
registration it counted. A generation from a default that
unregister() had dropped kept counting against whatever default was
registered next. So a host was refused over a fresh default with "a
snapshot is being generated from it now", although nothing was.
2. A generation that outlived its registration recorded its late
answer against a fresh registration of the same declaration. That
shut the fresh window, against unregister()'s own promise that the
record of a generation goes with the registration.
Now every change of registration goes through one helper: register(),
register_default() where it registers, and unregister(). The helper
moves a registration serial on and starts the two records afresh. A
generation carries the serial it began under. When it ends, it touches
the records only if that registration is still current. The generation
is not stopped, and its caller still gets its snapshot.
Tests: 66 -> 70 cases. The new case
test_a_generation_that_outlives_its_registration_touches_no_later_one
runs in four variants, in a process of its own. The follower is another
default or the same declaration, and the host registers either while
the generation runs or after it answers. Three variants are red against
4ca45d2's seam logic, and the fourth guards the new scoping.
test_unregister_clears_the_default_and_its_generation now registers the
same default afresh before the host tries. 15 of 15 mutations are
caught.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T054's branch (openDox-code#57) stacks on this one and rewrites the last
sentence of point 6 of tests/test_generator_seam.py's docstring. The
previous commit re-wrapped that same paragraph, so taking this branch
would have conflicted there. Point 6 is restored to its 4ca45d2 text,
and the new sentence moves to point 5, ONE REGISTRATION, where it
belongs. A trial merge of this head into #57's head 5a6fe63 is clean,
and 94 seam and projection cases pass in the merged tree.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot's review of this PR at bce09c5 (r4117421102) found that
current() read _registered twice: once for its None check, and once for
its answer. A thread that unregistered between the two reads made it
answer None after passing the check, against its SnapshotGenerator-or-
refusal contract. generate() calls it while holding the seam's lock, so
that path was safe, but a bare caller was not. current() now reads the
registration into a local once, and answers that local. It still takes
no lock of its own, because generate() holds the seam's lock, which is
not re-entrant, when it calls it.
Test: test_current_answers_the_registration_it_checked forces the
interleaving deterministically. A line tracer drops the registration
before every line of current() after its first. The test is red against
97b5b01's seam logic ("current() answered None after passing its
check") and green here. 16 of 16 mutations are caught, the new M16
among them.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 034)
#1144's 7.1, 7.1a, 7.1b and 7.2, with 7.1 as T007's batch G amends it
(R1Q11 (a) and R1Q12 (a), openxFactory#656 comment 5850003126).
- src/opendox/contracts/ holds the four packaged copies: ideation-workbench,
opendox-snapshot, xfactory-workbench-chat-turn and
xfactory-workbench-model-catalog. Each is byte for byte openDox-spec's file
at cd49eb25, the head of openDox-spec#16 (T053). copies.yaml is the record
that pins each copy's sha256. A copy is proved against it before a byte of
the copy is read, and a changed, absent or unpinned copy is refused.
- src/opendox/validator.py is the validator, new surface at the code leg
(7.2). It evaluates JSON Schema 2020-12, exactly the keywords the four
use. A refusal names its rule as [<x-rule>]. It implements the neutral
snapshot's seven reference rules, and it refuses a copy that uses anything
it does not evaluate. Its docstring records why the consumer's script
cannot be reused (7.1a), measured at openXdox-code 4610bca5.
- tests/test_validator_input_set.py is the falsifier: the packaged-copy
digest test and the 7.1b test, with the identity checks.
- tests/test_validator.py tests the evaluator over openDox-spec's own 47
examples, which cover all 32 rules, keyword by keyword, and over openDox's
own projection of T050's and T051's fixtures.
tests/fixtures/spec-examples/ is that corpus.
Held, and reported to the holder: the package-data line in pyproject.toml
for opendox.contracts. The phase-2 scope rule keeps drafts out of that file.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an 034)
A copy of gate-intent planted under src/opendox/contracts/schemas/ failed
the 7.1b test as "assert not True". Each of its four assertions now names
what it found: a kind, a kind's copy, a pinned copy, or a carried file.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 034)
CI's whole suite failed one case, because the two ideation-workbench
examples copied from openDox-spec were tracked:
tests/test_workbench.py::test_no_workbench_manifest_is_tracked_in_this_repo.
The leg's committed-manifest guard, workbench.committed_manifests, refuses a
workbench manifest tracked outside examples/, and it is right to. So those
two examples leave the corpus. A local run had passed only because it ran
before the files were committed.
The kind is held instead over the manifests openDox's own
workbench.Workbench writes: one of each seed kind, carrying every member
route, an exclusion, every action and a notebook binding. An override with
no recorded reason is refused as [required] at its member.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The holder has decided that the package-data line goes in this PR now.
openDox-code's phase-1 work has landed, so no phase-1 writer edits
pyproject.toml any more.
7.1 settles that the four copies travel as package data, "so `pip install
openDox-code` puts them on disk beside the validator". Until now the table
named only `web/**`, so a wheel carried opendox/contracts/__init__.py with
no record and no copy. Every installed validator then refused, naming the
absent record. The new key, "opendox.contracts", names copies.yaml and
schemas/*.schema.yaml. It is a separate key, because
test_gate_loop_contributed holds the bundle's `opendox = ["web/**"]`
verbatim.
test_the_package_data_ships_the_record_and_every_copy holds the table to the
record: the patterns under opendox.contracts ship exactly the record and
every copy it pins. The test fails without the line and passes with it.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reject malformed schema types with ValidatorUnavailable
src/opendox/validator.py:538
This malformed type value is not rejected as SchemaNotEvaluable: when a schema contains type: {}, set(names) raises TypeError. That breaks the fail-closed contract documented above, because a malformed/unsupported packaged schema can crash validator construction instead of producing ValidatorUnavailable; validate each entry is a string from _TYPES before building the set.
…in linear time
Copilot at openDox-code#68 09cd1e8.
- r4139734412: opendox.contracts converts only a MISSING packaged file to
CopyRefused. A record or copy that is present but unreadable (a
PermissionError, say) escaped validator_for() as itself, so
generate --strict ended in a traceback. The adapter now reports an
OSError from the lookup as validator-unavailable, with the error named.
The verb warns, or fails under --strict, in its own words. The root
conversion is opendox.contracts' (T057, #58), and it is relayed to its
owner.
- r4139734444: _names() tested membership against its own growing list,
which is quadratic, and the schema bounds none of the three lists. It now
tests against a set and keeps the list only for order.
Both new cases fail without their fix: the PermissionError traceback, and
17.5 s against 0.01 s for 100,002 entries.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…raised
The T058 writer measured this, from Copilot on openDox-code#68
(r4139734412). contracts._read_package_file turned only
FileNotFoundError, IsADirectoryError and NotADirectoryError into
CopyRefused. A record or copy that is present and cannot be read raised
PermissionError straight out of validator_for(): with copies.yaml at
mode 000, `generate --strict` exited 1 with a traceback.
Every OSError there is now a CopyRefused. A missing file keeps its
message. Any other names the file as present and unreadable, with the
error's class and reason. So the validator reports itself unavailable,
as it does for a missing file.
The regression case is test_a_present_file_that_cannot_be_read_is_refused_not_raised,
over the record and the neutral contract's copy, each at mode 000 in a
copied package (skipped as root, who reads it anyway). Both cases fail
against 3351f6a's reader. The whole suite: selected=2848 passed=2837
skipped=11, which is +2.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#58 took #57's final head and main a691e4e (T054 landed, #57), which this
branch already carries through #59 and #66. Its one new commit of its own,
3351f6af, refuses at build a const or enum holding a value JSON has not
(validator.py and its test).
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… not crashed on
Copilot at 2b8ad24 raised three threads. Two of them are real, measured
with an instance key of 10**5000. An int past 4300 digits has no decimal
text:
- r4139823704: Violation.where ran str() over each path part, so a
violation beneath such a key raised ValueError.
- r4139823779: the unexpected-properties report ordered the keys with
repr and showed the list with _brief, so that report raised too.
Now:
- A pointer part that str() cannot give is shown as _shown shows it,
`<an int of 16610 bits>`.
- Extra keys are ordered by _shown and shown item by item
(_brief_items), so one key too large to show is named by its size and
the rest still read as themselves.
- The schema side's unknown-keyword ordering uses _shown too.
- An ordinary key, pointer or report reads exactly as before.
r4139823750 does not reproduce. _canon_scalar answers None only for a
list or a mapping, and neither can be a key. A tuple key canonicalizes
as an opaque value, never equal to a JSON one. Measured:
- enum over {('non-text',): 1} gives one enum violation;
- uniqueItems over two such mappings gives one uniqueItems violation;
- neither crashes.
Tests, in tests/test_validator.py:
- test_an_instance_key_of_any_size_is_named_not_crashed_on fails against
2b8ad24's validator.
- test_a_non_text_key_that_is_not_a_scalar_is_judged_as_itself is the
guard for the claim that does not reproduce.
The whole suite: selected=2850 passed=2839 skipped=11, which is +2.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shot and the manifest
Copilot at openDox-code#68 69ca0e27 (r4139937566). float() rounds a valid
JSON number such as 1.0000000000000001 to 1.0, which then meets the
contract's const: 1. So generate called a snapshot valid whose
schema_version is not 1. jsonschema 4.26, the consumer's validator, reads
it the same way.
The contract has no number type, and its one numeric value is
schema_version's const: 1. So the adapter does not carry decimals through
opendox.validator (#58's module). It refuses a number that no float
verdict would be a verdict over. _exact() proves each float literal finite,
and equal to the shortest spelling of the float read from it. The refusal
is under document-syntax, "cannot be read as written". 0.1, 2.50, 1E2,
-0.0 and every float openDox's writer writes read as written, and any
integer reads exactly.
The workbench manifest's YAML floats go through the same proof, by a
SafeLoader subclass, so .inf, .nan and 1.0000000000000001 are refused
there too. Before this, the last read as "0 violations" and the others
reached the schema's const. The syntax detail now says "cannot be read as
JSON/YAML", which is also true of a valid but rounded literal.
The new cases fail without the fix (6), and seven controls pass either way.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#58's two new commits: opendox.contracts now refuses a packaged file that
is present but unreadable (2b8ad24, the finding relayed from #68,
r4139734412), and an instance key of any size is named in a pointer and a
report without a crash (753ffa1). The adapter's own OSError guard stays,
as defense in depth: a lookup error that is not ValidatorUnavailable still
reaches the verb as unavailable, not as a traceback.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#58 landed by squash as 8ec08e9. It merges here with no conflict, and
what the merge brings is exactly #58's squash: the staged diff is
byte-identical to `git diff a691e4e8ec08e9`. T055's validator
stand-in (default_projection.VALIDATOR) is left as it is. Swapping it
for opendox.validator is T058's work.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#59 took main 8ec08e9 (T057 landed, #58). T056 is stacked on #59, so it
carries #59's current head.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#66 carries #59's head c2a8ad9, which took main 8ec08e9 (T057 landed,
#58). This branch carried #58's head 753ffa1, whose content the squash
8ec08e9 repeats, so the merge adds nothing of T057's.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
**Authored ahead, as a phase-2 draft.** Brett ruled on openxFactory#656, comment `5857365170` ("Start phase 2's independent tasks"), that phase-2 tasks are authored as drafts. **T049 has landed** (openxFactory#1204 → `9d2e5bc3`), so phase 2 is open. This PR stays a draft until the two tasks below have landed and the holder un-drafts it.
T055 is `After: T054, T057, T022, T038`, and all four have landed (#57 `a691e4e4`, #58 `8ec08e91`, #45, #48). Before they landed:
- T054 is #57, a clean draft. It waits on #54, #56 and T053 (openDox-spec#16).
- T057 is #58, a clean draft. It also waits on the openDox root pinning T053's landed spec commit. See "T057 and the validator default" below.
- T022 (#45) and T038 (#48) have landed.
This is plan 034's **T055 (serve and generate standalone: 5.5, and 4.3 in part)**, phase-2 slice **P2-R**. I read the plan at openxFactory `main` `6b97c60` (`specs/034-opendox-standalone-operation/tasks.md`). The slice is claimed on openxFactory#656, comment `5873583149`.
## What this branch carries
- It is **built on #57's branch**, `build/034-p2p-t054-neutral-projection` (T054, the neutral projection), which is itself built on #54 (T052, the generator seam). It was cut at `5a6fe637`, with #54's head `8d507c5b` merged in as `e8a733d`.
- After T049 landed, **#57's head `8e7da4a2` is merged in as `8cbb1ea`**, clean. That head carries:
- #54's final head, `2f515c57`;
- #56's head, `44dcc3a1`;
- `main` `dc3765dd`, where #53 (T050) landed;
- the holder's scaffold decision (`8e7da4a`), which answers r4126022820 below.
- **#58 (T057) is not merged in**, on the holder's decision (below).
- Until #54, #56 and #57 land, the diff against `main` carries their changes as well. T055's own change is:
- its commits `c27eac35` through `f4ef63d3`: the first builds T055, and the other six take Copilot's review rounds;
- `d7aa9d8c` (the holder's decision on empty source options), and `687d37bf`, `96f18c45` and `e3ef506a` (the next three review rounds), all after T049.
## What it builds
### Four seams, each with an openDox default of its own (R1Q10 (a))
`src/opendox/projection_seams.py` is new and uses the standard library only. It declares four seams:
| seam | what a registration carries | openDox's default |
|---|---|---|
| `registry` | the entry, registry and source types; the key, containment and publishable-ref rules; the data-source readers; the refresh bindings and defaults | `opendox.default_registry` |
| `corpus_root` | `corpus_scan_defect`, `corpus_root_refusal`, `change_rows`, `SCANNED_ROOTS` | `default_projection.CORPUS_ROOT` |
| `writer` | `write_snapshot(snapshot, path, boundary)` | `default_projection.WRITER` |
| `validators` | one validator per KIND: `validate(path, *, strict, search_from)`, answering a `ValidationResult` | `default_projection.VALIDATOR`, for `opendox-snapshot` and `ideation-workbench` |
The rules are the ones the profile and generator seams keep (R1Q3 (a) and (ii); RN-1 (a)):
- **Nothing registered refuses** (4.2). The refusal names the seam, its call and the four entry points. There is never a silent fallback.
- **The entry points register the defaults where no host has.** They are `cli.build_parser()`, `cli.main()`, `serve.build_server()` and `serve.main()`. `register_defaults()` reads nothing.
- **A host's registration replaces a default until a consumer has read it**, and is refused (`SeamAlreadyRegistered`) after that. A host over a host is refused. The same object again is a no-op. `unregister()` makes a swap explicit.
- **Each registration is probed for its names when it is made.** A missing or non-callable name is a `TypeError` naming what is missing. A lookup that raises is chained as the cause (T027's rule).
- **A consumer resolves the seam at each use.** `serve.py` and `serve_workbench.py` bind `registry_mod = projection_seams.registry.proxy`, which resolves on every attribute read. No proxy is read at import time, and a test walks the AST to hold that.
The defaults are new code, not openXdox's modules moved.
- **`default_registry`** is one in-memory registry keyed `(repository, ref)`, with `ref` defaulting to `main`.
- It keeps `/source`'s containment rule (`resolve_within`).
- Its `SnapshotSource` serves the snapshot it was handed, and it regenerates that snapshot.
- It reads **no data source and no index**, because openDox has no index kind (T053). A declared `--data-source-*` or `--local-index` is **refused**, naming the seam, rather than ignored.
- `compose_view` composes nothing.
- **`CORPUS_ROOT`** asks, structurally, for a git repository's root: a directory holding `.git`, and a worktree's `.git` file counts. Its `SCANNED_ROOTS` is empty, and `change_rows` answers no row.
- **`WRITER`** writes canonical JSON (sorted keys, indent 2, ASCII, a trailing newline) through the interactivity boundary.
- **`VALIDATOR`** is a stand-in until T057 is in this tree. It answers `validator-unavailable`, naming T057, and never answers `validated`.
### The verbs go through the seams (4.3, in part)
- **Generation.** `cli._generate_and_write` and `cli._gate_snapshot` call `generator_seam.generate(...)`, which looks the generator up on each call.
- Both write through the registered writer.
- An unset `--project-register` or `--possibles` is not passed.
- A set one that the registered generator does not declare is refused (`generate refused: …`) before anything is written.
- **The validator is chosen by the written snapshot's `kind`** (`cli._validate_by_kind`). `_locate_validator` is gone.
- The three outcomes keep their meaning, and so do `--strict` and `--no-validate`.
- A kind with no validator is *unavailable*. It is never given another kind's validator.
- A snapshot that declares no kind fails.
- The remedy line prints only when the validator declares a `dependency_remedy`.
- **The workbench manifest.** `workbench.validate_manifest` asks the lookup for `ideation-workbench`, and no longer calls `consumer_reach.find_validator`. An explicit validator script still runs as before.
- **The regenerate.** The default source's regenerate (`SnapshotSource.refresh`) generates through the seam and writes through the registered writer. It keeps `main` active and never promotes a session ref (FR-014a).
- **`branch_session`.**
- `session_entry`, `register_session_entry`, `refresh_session_snapshot` and `refresh_main_view` use the registered registry's types.
- `_change_rows` (the brief's `:1568`) asks the corpus-root seam's `change_rows`, because the generator seam does not carry it.
- **The RFC 3339 check.** `is_rfc3339_datetime` is openDox's own (`src/opendox/rfc3339.py`). It implements the neutral contract's `generated-at-is-rfc3339` rule, which no seam carries, and it agrees with openXdox's check over 20 vectors.
- **The core `/snapshot.json` arm is `serve.py`'s own.**
- `_query_key`, `_read_snapshot`, `_serve_snapshot` and `_hosted_entry_refused` are now defined on `DashboardHandler`.
- `hosted_ref_refused` is a function in `serve.py`, over the registry seam's `is_publishable_ref`.
- While they were forwarded to openXdox, a standalone server refused every `/snapshot.json`, and `_divergence_headers` asks `hosted_ref_refused` on every response.
- `_checkout_real` and `_refuse_impossible_checkout_root` ask the registered predicate.
- **`_report`** prints `repository=… kind=opendox-snapshot` for the neutral kind, which carries no project. Every other kind's line is unchanged.
- **The empty-projection warning** reads the registered predicate's `SCANNED_ROOTS`. With none, as openDox's own predicate has, it says "it was accepted as a corpus checkout, but nothing in it was read as a document". The governed wording is unchanged.
- **The `_default_home_factory` docstrings** in `cli.py` and `serve.py` now say that the adapter's default `required_fields` is `NEUTRAL_FIELDS` (`title` and `summary`, since T054), as #57 asked. The two copies stay identical apart from each naming the other.
- **`serve.main()` registers the defaults before building its parser**, because its option defaults read the registry.
- So `--help` now runs in a lone checkout, which research R7 found it could not.
- `--data-source-path`'s help reads `(default: the source root)`.
- A seam's refusal prints `serve refused: …` and exits 1.
### Retired from `consumer_reach`
- **The stand-ins.** `snapshot_registry`, `snapshot`, `corpus_root`, `generator`, `find_validator`, `corpus_root_refusal`, `generate_snapshot`, `is_rfc3339_datetime`, `hosted_ref_refused` and `scanned_roots` are gone.
- **The helpers.** The `function` and `constant` stand-in helpers are gone too, since nothing binds through them any more.
- **The projection column.** `LateProjectionRoutes` now forwards ONE method, `_serve_index`, for the column's own `/snapshot-index.json` binding. That binding is T084's.
## The falsifier
From T055's entry in tasks.md:
> **Falsifier**: F4.1's scan, down by these reaches.
The scan is `scan_deferred_reaches.py` from research.md's appendix (sha256 `fe116203…`, as in the remeasure table), run over `src/opendox`. At `e8a733d`, the branch before T055's commit:
```text
src/opendox/branch_session.py:1568: openxdox [in _change_rows]
src/opendox/branch_session.py:1587: openxdox.register [in _active_pick_fallbacks]
src/opendox/branch_session.py:2005: openxdox [in proposal_state_for]
src/opendox/branch_session.py:2105: openxdox.snapshot_registry [in session_entry]
src/opendox/branch_session.py:2151: openxdox.snapshot_registry [in register_session_entry]
src/opendox/branch_session.py:2228: openxdox.snapshot_registry [in refresh_session_snapshot]
src/opendox/branch_session.py:3573: openxdox.snapshot_registry [in refresh_main_view]
src/opendox/cli.py:632: openxdox.snapshot_registry [in _session_registry]
src/opendox/serve.py:652: openxdox.corpus_root [in _checkout_real]
src/opendox/serve.py:2125: openxdox.corpus_root [in _refuse_impossible_checkout_root]
src/opendox/serve_project.py:246: openxdox.gate_console [in _serve_project_register]
src/opendox/serve_project.py:247: openxdox.kickoff [in _serve_project_register]
src/opendox/serve_workbench.py:347: openxdox [in _is_live_session_ref]
src/opendox/serve_workbench.py:407: openxdox [in _thread_gate]
src/opendox/serve_workbench.py:408: openxdox [in _thread_gate]
src/opendox/serve_workbench.py:544: openxdox [in _handle_workbench_thread]
src/opendox/serve_workbench.py:1215: openxdox [in _handle_workbench_model_approval]
src/opendox/serve_workbench.py:1665: openxdox [in _handle_workbench_chat_turn]
src/opendox/serve_workbench.py:2607: openxdox [in _handle_workbench_document_abstract]
19 deferred reaches
```
At `c27eac35`:
```text
src/opendox/branch_session.py:1592: openxdox.register [in _active_pick_fallbacks]
src/opendox/branch_session.py:2010: openxdox [in proposal_state_for]
src/opendox/serve_project.py:246: openxdox.gate_console [in _serve_project_register]
src/opendox/serve_project.py:247: openxdox.kickoff [in _serve_project_register]
src/opendox/serve_workbench.py:351: openxdox [in _is_live_session_ref]
src/opendox/serve_workbench.py:411: openxdox [in _thread_gate]
src/opendox/serve_workbench.py:412: openxdox [in _thread_gate]
src/opendox/serve_workbench.py:548: openxdox [in _handle_workbench_thread]
src/opendox/serve_workbench.py:1219: openxdox [in _handle_workbench_model_approval]
src/opendox/serve_workbench.py:1669: openxdox [in _handle_workbench_chat_turn]
src/opendox/serve_workbench.py:2611: openxdox [in _handle_workbench_document_abstract]
11 deferred reaches
```
- **The eight reaches that are gone** are the ones T055 names:
- `branch_session`'s `_change_rows`, `session_entry`, `register_session_entry`, `refresh_session_snapshot` and `refresh_main_view`;
- `cli`'s `_session_registry`;
- `serve`'s `_checkout_real` and `_refuse_impossible_checkout_root`.
- **The eleven that remain** are all T084's.
- **The census of `consumer_reach` sites** (`census_consumer_reach.py`, sha256 `7f36b848…`) falls from 65 to 29: `gate_console` 27, `LateGateRoutes` 1 and `LateProjectionRoutes` 1. No projection name remains.
**Standalone, end to end:** `tests/test_projection_seams.py::test_generate_and_serve_run_with_no_sibling_importable` runs in a process where `openxdox`, `ideation_dashboard`, `doc_health` and `corpus_adapter_openxfactory` cannot be imported.
- `generate` exits 0 and writes `kind: opendox-snapshot`.
- `serve.build_server` builds.
- The server answers `GET /snapshot.json` 200 (the neutral kind), `/capabilities` 200, `/source/notes-toolshed-inventory.md` 200 and `/source/.git/config` 404.
- No sibling module is loaded.
## The repository's own checks
- **The whole suite, against a `postgres:16` container** (CI's service and DSN shape), on Python 3.12.3 with pytest 8.4.2. The venv was installed with `-c constraints-cpython312-linux.txt -e ".[runtime,test]"`, and `LANG=C.UTF-8`, as the runner sets it.
| run | passed | skipped |
|---|---|---|
| base `e8a733d` (the branch before T055's commit) | 2583 | 11 |
| head `f4ef63d3` | 2701 | 11 |
The test-by-test JUnit diff shows **127 added**, all passing:
- 117 in the new `tests/test_projection_seams.py`;
- 4 new `NEUTRAL_MODULES` parameters in `test_consumer_reach.py`;
- 6 in `test_source_core_arm.py`.
It shows **9 removed**. Each subject is retired or replaced:
- Four tests exercised `consumer_reach.constant()`, which is retired: `test_a_constant_does_not_forward_attribute_reads`, `test_constructing_a_constant_resolves_nothing`, `test_the_sequence_operations_a_re_exported_constant_meets` and `test_an_unavailable_consumer_refuses_at_the_operation_not_at_the_binding`.
- `test_a_converted_name_is_never_used_at_import_time[serve.py|serve_workbench.py|workbench.py]`: those modules bind no `consumer_reach` name any more. The import-time property of the proxies they bind instead is held by `test_no_proxy_over_a_seam_is_read_at_import_time`.
- `test_the_column_keeps_the_rules_the_ruling_did_not_move` asserted that `_serve_snapshot` and `_hosted_entry_refused` stay forwarded, which T055 reverses. It is replaced by `test_the_column_keeps_only_its_own_contributed_route` and `test_the_snapshot_arms_handlers_are_defined_on_this_handler` (four cases).
- `test_hosted_ref_refused_is_still_reached_through_the_late_seam` is replaced by `test_hosted_ref_refused_asks_the_registered_registrys_rule`.
**0** outcomes changed.
- **The triple pin** reads: selected 2712 (floor 2476), passed 2701 (floor 2465), skipped 11 (pinned exactly 11). No test ships skipped. CI's own line at `f4ef63d3` reads `triple: selected=2712 passed=2701 skipped=11 failures=0 errors=0`.
- **One environment note.** In a shell with no `LANG`, `test_model_provider_broker.py::test_the_broker_child_inherits_no_credential_shaped_environment` fails. The child Python's PEP 538 locale coercion adds `LC_CTYPE`, which is not in the bridge's allowlist. With `LANG=C.UTF-8` it passes. T055 touches neither the broker nor the bridge, so this predates it.
- **Mutations: 34 of 34 killed.** Twenty-three were run at `c27eac35`:
- Seams:
- a host replaces a default after it was read;
- reading a default does not close its window;
- `register_defaults` skips the writer;
- a proxy resolves dunders;
- a missing kind answers another kind's validator.
- Validation:
- the validator is chosen by a fixed kind;
- the manifest is validated by another kind's validator.
- Serving:
- the hosted plane never refuses a session ref;
- an active session entry is served hosted;
- the checkout is never real.
- Containment: files below a dot-directory are served.
- Regenerate:
- it promotes a session ref;
- it bypasses the registered writer.
- Registry:
- the neutral source ignores a declared data source;
- the session entry type is not the registered registry's;
- the CLI's session registry is not the registered registry's.
- Routed sites:
- `_report` prints the governed line for the neutral kind;
- `_change_rows` ignores the registered predicate;
- the predicate accepts a directory with no `.git`;
- `generate` skips the corpus-root guard;
- the gate snapshot bypasses the seam.
- RFC 3339:
- it admits a space for the `T`;
- it refuses a lower-case `t`.
One more mutation, admitting second 60, is equivalent, because `datetime` refuses it too.
Eleven more were run in the review rounds, one or two for each change:
- a symlink leads to what a name cannot;
- a declared token variable is dropped;
- dropping the active entry leaves its key;
- an explicitly empty URL is dropped for being falsy;
- an unregistered kind is described as a search;
- the lookup's refusal is cut before its remedy;
- the writer writes NaN and Infinity;
- the snapshot body re-resolves the active entry;
- the source arm resolves the entry twice;
- the writer rewrites the snapshot in place;
- a failed move leaves its temporary sibling.
- **pyflakes over the touched files** finds nothing new: 104 findings at base and 104 at head, all pre-existing re-export F401s.
## T057 and the validator default
- **What T055 asks for.** T055's text makes the lookup's default openDox's own validator (T057).
- **Where T057 is.** When this branch was cut, T057 had no PR. It now has one, #58 (a draft at `ca52182f`, stacked on #57's branch). This PR does **not** merge it, for two reasons:
- #58 edits `pyproject.toml`, which phase-2 drafts in this lane stay out of;
- #58 is still in review.
- **What happens meanwhile.** The default for openDox's two kinds is the stand-in, `OwnValidatorNotBuilt`, which names T057.
- A generate verb warns "validation SKIPPED" and exits 0, or 1 under `--strict`.
- A manifest saved with `validate=True` is refused as unvalidated. That is what a lone openDox answered before T055 whenever no validator was reachable, except that `validate_manifest` no longer reaches the consumer.
- **The swap is one registration** (see T058 below).
- **The holder's decision (2026-09-28): do NOT merge #58 into #59.** The validator stand-in stays until T058 wires `opendox.validator`. This branch takes #58's code only through `main`, once #58 has landed, and still keeps the stand-in until T058.
## Beyond the task's list: `hosted_ref_refused`
- **Where the plan put it.** T084's list names `hosted_ref_refused` (2 sites).
- **Why T055 routes it.** The core `/snapshot.json` handlers that T055 makes `serve.py`'s own call it, and so does `_divergence_headers`, on every response. Without it, a standalone server could not answer `/snapshot.json` or `/capabilities`.
- **So T084's list is one name shorter.**
## Files (T055's own commits)
- **New:**
- `src/opendox/projection_seams.py`
- `src/opendox/default_registry.py`
- `src/opendox/default_projection.py`
- `src/opendox/rfc3339.py`
- `tests/test_projection_seams.py`
- **Changed:**
- Source: `src/opendox/cli.py`, `serve.py`, `serve_workbench.py`, `workbench.py` and `branch_session.py`.
- `consumer_reach.py`.
- Docstrings only: `serve_wire.py`, `generator_seam.py`, `default_generator.py` and `cli_project.py`.
- Tests: `test_consumer_reach.py`, `test_source_core_arm.py`, `test_route_handler_contribution.py`, `test_doxbench_entrypoint.py`, `test_generator_seam.py`, `test_profile_registration.py`, `test_authoring_seam.py`, `test_reach_sweep.py` and `test_neutral_projection.py`.
- **Not touched:** `conftest.py`, `pyproject.toml`, `.github/workflows/validate.yml`, `README.md`, and every pin. The new modules are created files, with no carve-manifest row (RULED OQ-C).
## What the later tasks need
- **T056** (the standalone generate path, end to end). I probed this at `c27eac35`, with the four siblings blocked by a meta-path blocker, over a copy of T050's fixture. In that copy, `candidate-toolshed-rebuild.md`'s `stage:` is `brainstorming`.
- `python -m opendox.cli generate` exits 0 and writes the neutral snapshot. It prints `notice: candidate-toolshed-rebuild.md: its stage: value 'brainstorming' is not one of the six station role keys (source, grouping, candidate, selection, submission, completion), so it is not a declaration; the document is read as a source`. The snapshot reads that document as `stage: source`. So the verb's half of F5.3's `stage:` case needs no further plumbing.
- `generate-and-open --no-open --no-serve` exits 0, builds the server and prints its URL.
- What remains is T056's own:
- a test that the server STARTS and answers, run as `generate-and-open` without `--no-serve` (or `serve.main`) in a subprocess with the siblings blocked, polling the printed URL and then stopping the server;
- F10.1's plain-install run, which is T070's.
- **T058** (the post-render validator in the generate verbs).
- Replace `default_projection.VALIDATOR` with an adapter over #58's `opendox.validator` that keeps T055's protocol: `validate(path, *, strict=False, search_from=())` answers a `projection_seams.ValidationResult`.
- How the adapter should work:
- It reads the file: JSON for `opendox-snapshot`, and YAML for the `ideation-workbench` manifest, which `workbench.py` already parses with PyYAML.
- It calls `validator_for(kind)`, or `validate(instance, kind=…)`.
- How its answers should map:
- No violation is ok, with return code 0.
- Violations are `not-conformant`, with return code 1 and `report(violations)` on stdout, so each rule id is named. F7.2 needs `EXPECTED_RULE`.
- `ValidatorUnavailable` and `SchemaNotEvaluable` are `validator-unavailable`, with the reason.
- `dependency_remedy = None`. No subprocess runs, so F7.2's "no `No such file or directory`" holds by construction.
- `OWN_KINDS` stays `(opendox-snapshot, ideation-workbench)` unless T058 widens it to #58's `KINDS`. The doxBench wire kinds keep their own path (`serve_wire.register_doxbench_validators`).
- The stand-in's cases in `tests/test_projection_seams.py` change with it:
- `test_the_validator_stand_in_concludes_nothing_and_names_T057`;
- `test_openDoxs_own_kind_meets_the_stand_in_and_strict_makes_it_fatal`;
- the stand-in half of `test_a_manifest_is_validated_by_the_validator_for_its_kind`.
- `--strict` and `--no-validate` already mean what T058 says.
- **T059** (openXdox registers its governed mechanisms). It registers everything below at process start, before any entry point reads a default, because a default is sealed once read.
- `openxdox.snapshot_registry` at `projection_seams.registry`. The module already carries every required name.
- An adapter at `corpus_root`. It takes `openxdox.corpus_root`'s `corpus_scan_defect`, `corpus_root_refusal` and `SCANNED_ROOTS`, plus a `change_rows` built from `generator.iter_changes` and `declared_origin_state`, which is what `branch_session._change_rows` computed before T055.
- `openxdox.snapshot` at `writer`.
- A validator adapter for each governed kind, such as `ideation-dashboard-snapshot`. Its `dependency_remedy` is `snapshot.DEPENDENCY_REMEDY`, and it names `VALIDATOR_RELPATH` in `unavailable_reason`.
- It lowers `OPENDOX_BACK_IMPORTS` in `tests/test_dependency_direction.py`. `cli.py` and `serve.py` leave the table, and `branch_session.py` goes from `(0, 7)` to `(0, 2)`. `serve_project.py` stays at `(0, 2)` and `serve_workbench.py` at `(0, 7)`.
- `tests/test_generated_at_anchor.py:223` patches `cli_mod._locate_validator`, which T055 removed. The seam to patch now is `_validate_by_kind`, or a validator registered for the kind.
- Four gaps that the review rounds closed in openDox's own defaults are still open in the governed mechanisms T059 registers. They are openXdox-code's to close, ideally before T059 registers them:
- `snapshot_registry.resolve_within` checks the hidden-name rule only against the URL's spelling, so `link -> .git` serves `/source/link/config`, which is `.git/config` (r4125556296).
- `SnapshotRegistry.drop` leaves the active key pointing at a dropped entry.
- `snapshot.write_snapshot` writes in place through `write_output`, so a request can read a truncated snapshot (r4126138808). Its `canonical_json` also writes NaN and Infinity (r4125900060).
- `SnapshotRegistry` reads `active`, `get`, `entries()`, `keys()` and `len()` without its lock, so a reader can answer from the middle of a block `atomically()` holds (r4136863481).
- **T084**: its list no longer includes `hosted_ref_refused`. The eleven reaches the scan still finds are all T084's.
## For the holder
All three earlier items are now decided:
- **The validator default**: #58 is not merged here (above).
- **T054's `NEUTRAL_FIELDS` default missed openDox's own scaffold** (r4126022820): #57's commit `8e7da4a` takes it, and this branch now carries that commit. openDox's own scaffold leads with `title:`/`summary:` where the registered corpus obliges them, and keeps the governed layout where it obliges neither.
- **Empty generator inputs**: the holder ruled that an empty `--project-register`/`--possibles` is REFUSED, fail closed. It is neither dropped nor read as `Path("")`. Taken in `d7aa9d8c`:
- `cli.SourceOptionRefused`, raised by one helper, `_source_option()`, which both call sites use: `_generate_and_write` for `generate` and `generate-and-open`, and `_gate_snapshot` for the gate verbs. It answers `None` for an option not given, refuses an empty one, and resolves anything else.
- The guard runs beside the other two, before any generation or write. In `generate-and-open` it runs early too, so a refused run mints no run directory. `main()` prints `<verb> refused: …` and exits 1.
- `serve.main` refuses an empty `--project-register` the same way, before a socket is bound.
- Seven new cases. Mutations: 8 of 9 killed. The ninth removes the explicit guard in `_generate_and_write`, and it is equivalent: the argument expression refuses before `generate()` is called. The guard is kept beside the other two, so the order does not rest on argument evaluation.
## CI and review rounds
`validate` is green at every head below, and SonarCloud passed its quality gate. Every thread was answered and resolved, and each change carries a case that its mutation fails.
- **`c27eac35`: 3 threads, all taken in `ea49c424`.**
- r4125556296: symlinks inside the root could lead into a dot-directory. The canonical path now meets the hidden-name rule too.
- r4125556360: `token_env` was ignored. Every declared data-source option is now refused by name, an empty one included.
- r4125556420: an extra "by", in three places.
- The overview's remarks on `display.js` and the plain-documents fixture test are #57's and #53's files. They have no threads here and are flagged, not changed.
- **`ea49c424`: 1 thread, taken in `bfb9c479`.**
- r4125666251: `serve.main`'s `or` turned an explicitly empty `--data-source-url`/`--data-source-github` into `None`. Each option is now handed over as given, and a slug that cannot be composed is refused.
- The overview (no thread) found that `drop` left the active key dangling. That is taken in the same commit.
- **`bfb9c479`: no findings.** The overview (no thread) noted that the unavailable-validator warning described a filesystem search even when nothing is registered for the kind. It is taken in `244d7b7f`: the warning now says which of its two cases happened, and gives the lookup's whole refusal, remedy included.
- **`244d7b7f`: 2 threads, taken in `19c26782`.**
- r4125900060: the writer wrote NaN and Infinity. It now refuses what JSON cannot carry, with `SnapshotNotWritable`, before writing.
- r4125900164: the snapshot arm's query-less path read the active entry twice, for the refusal and for the body. It now resolves the entry once. This race came from openXdox's handlers unchanged.
- **`19c26782`: 2 threads.**
- r4126022918: the source arm resolved its entry twice. It now resolves the entry once and asks for the path by that entry's own pair. Taken in `8c09d74c`.
- r4126022820: T054's `NEUTRAL_FIELDS` default. It is answered with a reproduction, not changed here, and flagged above.
- **`8c09d74c`: 1 thread, taken in `f4ef63d3`.**
- r4126138808: the writer wrote in place, so a concurrent read could see a truncated snapshot. It now writes a temporary sibling and moves it over the target with one `os.replace`, and the boundary still decides the destination.
- **`f4ef63d3`**: `validate` is green, with `selected=2712 passed=2701 skipped=11 failures=0 errors=0`. Copilot's review run there ended with "Copilot encountered an error and was unable to review this pull request", so it posted no verdict.
- **After T049: `8cbb1ea` merges #57's head, and `d7aa9d8c` takes the holder's decision on empty source options** (above). CI is green at `d7aa9d8c`, with `selected=2723 passed=2712 skipped=11`: #57's 4 scaffold cases and these 7.
- **Copilot at `d7aa9d8c`: 1 thread, taken in `687d37bf`.**
- r4136439187: a refresh widened a restricted snapshot. `os.replace` carries the sibling's mode over the target, and the sibling had a new file's ordinary mode.
- Now, where the target exists, its permission bits go onto the sibling (`os.fchmod`) before a byte is written. A new target keeps the ordinary mode.
- If the bits cannot be copied, the write fails and nothing is widened. The copy runs inside the stream's `with`, so the descriptor cannot leak.
- Four new cases, all red against `f4ef63d3`'s writer. Local whole suite: `selected=2727 passed=2716 skipped=11`.
- **Copilot at `687d37bf`: "Changes recommended", 2 threads.** CI is green there, with `selected=2727 passed=2716 skipped=11`.
- r4136585695, taken in `96f18c45`, and taken again without a lock in `e3ef506a` (next round). The source arm asked `resolve_source` for the path by the entry's pair, and `resolve_source` looks the pair up again. So a refresh that re-registered the same key between the two lookups could put another root behind the path.
- `96f18c45` ran both lookups under the registry's own lock, `self.source.registry.atomically()`, the lock `register` and `drop` take.
- Its case ran the refresh on another thread. It was red against `687d37bf`'s arm, and red with the hold removed.
- r4136585637, not taken: it does not reproduce. `_register_baked` sets `baked_repository` (line 560) before it asks `_source_root_for` (line 561), and has done since `c27eac35`.
- Measured with `--repository` omitted: the entry is confined to the served checkout, and `/source/notes-toolshed-inventory.md` answers 200, keyed or not.
- Two existing tests pin the order, and both are red with the two lines swapped: `test_the_source_registers_the_snapshot_it_was_handed` and `test_generate_and_serve_run_with_no_sibling_importable`.
- Local whole suite at `96f18c45`: `selected=2729 passed=2718 skipped=11`, which is +2.
- **Copilot at `96f18c45`: "Changes recommended", 3 threads, all taken in `e3ef506a`.** CI is green there, with `selected=2729 passed=2718 skipped=11`.
- r4136863569: the seam does not declare `atomically()`, so a registry that passes the seam's probe could fail every `/source` request.
- The arm now asks nothing of the registry beyond the one resolution. It confines the path to the resolved entry's own root through `resolve_source_path`, its single entry point and the one the no-registry path already used. That applies the declared per-entry rule, `resolve_within`, as `serve_workbench.py` does for the roots it holds.
- There is no second lookup, so r4136585695's race stays closed with no lock.
- `tests/test_source_core_arm.py` pins the shape: one `resolve`, the entry's own root through `resolve_source_path`, and no `resolve_source` or `atomically` call in the arm. It fails against `687d37bf`'s arm and `96f18c45`'s.
- The race case, now `test_a_refresh_that_replaces_the_key_cannot_move_the_source_arms_root[unkeyed|keyed]`, shows the replacement lands before the path is confined, and the body is still the first entry's file. It is red against `687d37bf`'s arm.
- r4136863481: `get`, `active` and `len()` read without the registry's lock, so a reader could answer from the middle of a block `atomically()` holds.
- All three now read under the lock, as `entries()` and `keys()` already did.
- `test_a_read_waits_for_a_held_read_modify_write[active|resolve|get|len]`: a read on another thread starts while a held block has a session promoted, and it must answer as the block left the registry. Each case is red with its own lock removed.
- openXdox's registry has the same gap, listed for T059 above.
- r4136863311: a registered generator owes the seam only `kind` and an integer `schema_version`, and `_report` indexed `repository` and `generation`, so such a snapshot raised `KeyError` after it was written.
- The report now shows a lacking field, or one in another shape, as `<absent>`. `_stats` counts only a list or a mapping. The verb then goes on to the kind's validator.
- `test_generate_reports_a_snapshot_whatever_else_its_contract_carries`, over a minimal snapshot and one with other shapes. Both are red against `96f18c45`'s report, with `KeyError`.
- Local whole suite at `e3ef506a`: `selected=2735 passed=2724 skipped=11`, which is +6. F4.1's scan is unchanged, at 12.
## After #57 and #58 landed
- `92a9167` merges #57's final head `d7954fc6`, and `814516b7` merges `main` `a691e4e4` (#57), resolving its two add/add conflicts to ours: main's blob is `d7954fc6`'s in each. `c2a8ad9f` merges `main` `8ec08e91` (#58) with no conflict, and what it brings is exactly #58's squash. The validator stand-in stays until T058.
- **Copilot at `c2a8ad9f`: 1 thread, taken in `183b40fc`.** r4146219833: `_regenerate` passed `project_register_source` even when unset. It is now passed only when set, as the seam omits an unset input. `test_an_injected_generator_is_handed_the_register_only_when_one_is_set`, red against `c2a8ad9f`.
- **Copilot at `183b40fc`: 1 thread, not taken, made explicit in `0c946f4e`.** r4146370161 asked for `main` to be restored after an already-active session is regenerated. FR-014a forbids promoting a session, and an entry that was already active is not promoted by its own refresh. The docstring now says so, and `test_a_regenerate_promotes_no_session_and_moves_no_active_key` pins both halves.
- **Copilot at `0c946f4e`: "Needs a closer look", no findings, 0 threads.** CI is green, with `selected=2989 passed=2978 skipped=11 failures=0 errors=0`.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…66)
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Plan 034 **T056** [US2] [oDc], **the standalone generate path, end to end** (#1144's 5.1, in part), from `specs/034-opendox-standalone-operation/tasks.md` at openxFactory `main` `91e4685f`:
> `python -m opendox.cli generate` and `generate-and-open --no-open` run on the fixture with neither sibling importable, and the server STARTS (the limit measured in research R7 is lifted).
> - **Falsifier**: F5.3; F10.1's `generate-and-open` run through `python -m opendox.cli`, with a plain install and no `--local`, which arrives in phase 3 (T070); and the verb's half of spec.md's `stage:` edge case. `python -m opendox.cli generate`, over a copy of T050's fixture in which one document declares a `stage:` value outside the six role keys, reports it, naming the document, the value and the six keys, and the snapshot it writes reads that document as a source. T054 tests the projection's half in process. F10.1 as batch H amends it is T077's.
> - **Ruled**: R1Q22 (a), `5817152735`.
> - **After**: T055.
Claimed on openxFactory#656 in comment `5901343950`.
## Was stacked on #59 (T055), and is now on `main`
- The PR was cut at #59's head `e3ef506a`. #59's heads `814516b7`, `c2a8ad9f` and `183b40fc` were merged in at `5a26532e`, `e3574774` and `38761c76`.
- **#59 has landed** as `fa140875`, a squash of `0c946f4e`, whose tree `fa140875` equals. The base is now `main`, and `main` is merged in at `a6e953ce`.
- #59 had moved after `183b40fc` only in `default_registry.py` and `tests/test_projection_seams.py`. T056 touches neither, so the two add/add conflicts took main's side.
- **The merge carries exactly T056's delta.** The stable patch-id of `git diff 183b40f38761c7` equals the patch-id of `git diff main a6e953c`: `eee3f01c` both times.
- This PR's diff is T056's alone, four files. #57 (`a691e4e4`), #58 (`8ec08e91`) and #59 (`fa140875`) have all landed, so nothing gates it but its own review.
- F10.1's plain-install run is **not** here. It needs the console script with no `--local`, which arrives in phase 3 (T070, and T077 as batch H amends it).
## What T056 found, and the one fix it needed
At #59's head the verbs already run standalone. T055's writer measured the verb's half of F5.3 there, and this PR confirms it. The server also starts. **But on a pipe, it never says where it started.**
- `cli.cmd_generate_and_open` and `serve.serve` print the URL and then block in `serve_forever()`. Neither flushed first.
- When standard output is a pipe or a file, Python buffers it by block. So a wrapper reading the URL never sees it while the server runs. It cannot learn an ephemeral port, or tell that the server started.
- Measured at `e3ef506a`, with the siblings blocked and `--port 0`:
| entry point | stdout on a pipe, default buffering | with `PYTHONUNBUFFERED=1` |
|---|---|---|
| `python -m opendox.cli generate-and-open --no-open` | 0 lines in 20 s | 8 lines, the URL in 0.4 s |
| `python -m opendox.serve --snapshot … --checkout-root …` | 0 lines in 15 s | the URL in 0.2 s |
- **The fix** is `flush=True` on the URL line and on "serving until interrupted" in `cli.py`, and on `serve.serve`'s announcement in `serve.py`. Each carries a comment saying why.
## The tests: `tests/test_standalone_generate_path.py` (new)
Each case runs a real child process, `python -m …`, the way F5.3 is written. The child is built by `tests/standalone_child.py`, a new helper module that holds no case. T058's F7.2 case uses the same helper.
**How "neither sibling is importable" is made true.** A `sitecustomize` sits on a directory put first on `PYTHONPATH`.
- It installs a meta-path finder that refuses `openxdox`, `ideation_dashboard`, `doc_health` and `corpus_adapter_openxfactory`, whatever is installed. It also drops any of them that a `.pth` file imported before it ran.
- It logs every name it refuses, and **each case asserts that the log is empty**. A refused import that an `except ImportError` swallowed would otherwise pass as a degraded run.
**The child's stdout is a pipe with default buffering.** `PYTHONUNBUFFERED` is taken out of its environment on purpose, because that is what a wrapper sees.
| case | what it holds |
|---|---|
| `test_F5_3_generate_through_the_module_writes_the_neutral_snapshot` | #1144's F5.3: `generate` over a fresh copy of T050's fixture. The snapshot is non-empty and of kind `opendox-snapshot`, and none of F5.3's 14 declared words is in a string value. |
| `test_the_verb_reports_a_stage_outside_the_six_and_reads_it_as_a_source` | `candidate-toolshed-rebuild.md` is edited to `stage: someday`. There is exactly one `notice:`, naming the document and `'someday'`, and carrying the six keys as one rendered list, `(source, grouping, candidate, selection, submission, completion)`. The document's entry is `stage: source`, and no string value in the snapshot contains `someday`. |
| `test_the_unedited_fixture_declares_that_document_a_candidate` | The control: as T050 ships it, the same document is a `candidate` and draws no notice. |
| `test_generate_and_open_starts_a_server_that_answers_with_no_sibling` | `generate-and-open --no-open --port 0`, with no `--no-serve`. The URL is read off the pipe while the child runs. `/index.html` is 200 and HTML. `/snapshot.json` is 200 and equals the written file. `/capabilities` is 200, with the regenerate binding. `/source/notes-toolshed-inventory.md` is 200, byte-equal to the document. `/source/.git/config` is 404. SIGINT exits 0, and the port is closed afterwards. |
| `test_serve_main_starts_a_server_that_answers_with_no_sibling` | The same checks for `python -m opendox.serve`, over a snapshot `generate` wrote. |
| `test_a_child_that_ignores_the_interrupt_is_killed_at_the_deadline` | The harness itself: a child that ignores SIGINT is killed at the deadline, and the timeout is raised, so a server that will not stop is reported rather than waited out. |
| `test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it` | The harness itself: a child stops on the interrupt with status 0 **even when the runner ignores SIGINT**, as a suite started with `nohup … &` does. The parent's handler is back afterwards. See the section below. |
## What the merge of `main` exposed: the runner's ignored SIGINT
After merging `main`, I ran the whole suite as `nohup pytest … &`. **Cases 3 and 4 failed** with `TimeoutExpired` at the interrupt. Run in the foreground, the same module passed 6 of 6.
The cause is the runner, not the server:
- POSIX starts an asynchronous command with SIGINT ignored when job control is off. Under `nohup … &`, a probe reads `signal.getsignal(SIGINT) == 1`, which is `SIG_IGN`. In the foreground it reads `default_int_handler`.
- An ignored signal survives `exec`, and Python installs its KeyboardInterrupt handler only where SIGINT was not ignored. So every server the harness started ignored the interrupt, and the two cases reported how the suite had been launched.
- The estate runs long suites exactly this way. Any lane running this module under `nohup` would have read a false red.
**The fix is in the harness, not the product.** A program started with SIGINT ignored should keep it ignored. The child's `sitecustomize` in `tests/standalone_child.py` now sets SIGINT back to `signal.default_int_handler`. That models what the cases claim, a Ctrl-C at a terminal. A child that ignores SIGINT itself, after startup, still does, and case 5 still kills it at the deadline.
**The falsifier** is `test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it`. It builds that runner in process: it ignores SIGINT while the child starts, and restores it at once.
- Before the fix it is **red** (`TimeoutExpired` at 10 s), and that is in a foreground run.
- After the fix it is **green**.
- The module under `nohup … &` went from **2 failed, 4 passed** to **7 passed**.
- Mutant M14, the reset removed, is killed by it.
## The falsifiers, failing before and passing after
**F5.3, verbatim from #1144** (a fresh venv, `pip install ".[test]"`, the sibling-absence assertion, `python -m opendox.cli generate`, then the vocabulary scan):
- At openDox-code `main` `fa8862cc`, before T055 routed the verb, it exits **1**:
`opendox.consumer_reach.ConsumerReachUnavailable: 'openxdox.corpus_root' belongs to openXdox, the layer that PINS this one, …`
- At this head it exits **0**:
```
wrote …/snap.json
repository=fixture kind=opendox-snapshot
documents=8 clusters=2 possibles=1 staged_topics=1 changes=2 keywords=28
documents: 8
```
**The verb's half of the `stage:` edge case**, over a copy of the fixture with one document edited to `stage: someday`:
```
notice: candidate-toolshed-rebuild.md: its stage: value 'someday' is not one of the six station role keys (source, grouping, candidate, selection, submission, completion), so it is not a declaration; the document is read as a source
```
The snapshot reads that document as `['source']`.
**This file**:
- At `main` `fa8862cc`: **5 failed**. Every verb refuses with `ModuleNotFoundError: No module named 'openxdox'`.
- At #59's head `e3ef506a`, without the two flushes: **2 failed, 3 passed**. The two server-start cases fail with `python -m opendox.cli generate-and-open never printed a line matching '^(http://…)/index\.html$' on its (buffered) standard output while it ran: exit status None, standard output ''`, and the same for `opendox.serve`.
- At `38761c76`: **6 passed** (3.4 s). It also passes under `--noconftest`.
- At `149d7295`, and at this head `a7bda066`: **7 passed**, both in the foreground and under `nohup … &` (4.6 s).
**Copilot:**
- At `65c943ff`, approval recommended, with no findings.
- At `1597511d`, one finding, r4139607689: an interrupt the child ignores held the caller while its pipe readers were joined. Fixed in `42a08a3a`: the child is killed before the join. The new case fails without the fix and passes with it. The thread is answered with that evidence and resolved.
- At `5a26532e`, one finding, r4139809410: the `stage:` case rejected only a string EQUAL to `someday`, not one containing it. Fixed in `e939c315`. A mutant that sets the summary to `stage: someday` passes the old check and fails the new one. Answered and resolved.
- At `e3574774`, one finding, r4146289331: "serving until interrupted" was checked only after the interrupt, when Python's exit flush delivers it anyway. Fixed in `caa01ca7`: the case reads the line while the child runs. Mutant M11, that line unflushed, now fails. Answered and resolved.
- At `38761c76`, approval recommended, with no findings.
- At `149d7295`, one finding, r4147767447: the `stage:` case checked each role key as a word anywhere in the notice. But `candidate` is in the document's name and `source` is in "read as a source", so a list that left both out still passed. Mutant M15, that list, SURVIVED the old case. Fixed in `a7bda066`: the whole rendered list is one substring. M15 is now killed. Answered and resolved.
- At this head `a7bda066`, a review is re-requested through the reviewer API.
## Mutation check: 15 of 15 killed, re-run at this head
Each mutant was applied to the source, the named cases were run, and the sources were restored and checked by sha256.
| mutant | killed by |
|---|---|
| M1: `generate-and-open` flushes neither line (the pre-T056 code) | the `generate-and-open` case |
| M2: `serve.serve`'s URL line is not flushed | the `serve` case |
| M3: `/source` serves a hidden path (both hidden-name checks dropped) | both server cases |
| M4: a sibling probe is swallowed on the generate path (`try: import openxdox / except ImportError: pass`) | F5.3, `stage:`, `generate-and-open` (the refused-import log) |
| M5: an out-of-six `stage:` is read as a candidate | `stage:` |
| M6: the notice omits the six keys | `stage:` |
| M7: the verb does not print the notice | `stage:` |
| M8: a governed word (`draft`) leaks into a title | F5.3 |
| M9: no `stage:` value is a declaration | the control |
| M10: `generate-and-open` exits non-zero on an interrupt | `generate-and-open` |
| M11: the serving line is not flushed (the URL line still is) | `generate-and-open`, which reads that line while the child runs |
| M12: the undeclared `stage:` value is carried inside a larger string | `stage:` |
| M13: an ignored interrupt is joined before the child is killed (the harness) | the ignored-interrupt case |
| M14: the child inherits the runner's ignored SIGINT (the `sitecustomize` reset removed) | the runner-ignores-SIGINT case |
| M15: the notice's key list leaves out `candidate` and `source`, which both appear elsewhere in the notice | `stage:` (survived the word-by-word check, which is why that check was replaced) |
Two first spellings were **equivalent**, and each was re-expressed as the real regression:
- Dropping only the URL line's flush is covered by the flush on the next line under `--no-open`.
- Dropping only the first hidden-name check is covered by the second, which still refuses `.git/config`.
## The repository's own checks
The whole suite ran locally as CI runs it: `CI=true`, `LANG=C.UTF-8`, PostgreSQL 16, `-e ".[runtime,test]"` with the constraints file, at the committed head, with a clean tree.
| tree | passed | skipped |
|---|---|---|
| #59's head `e3ef506a` | 2724 | 11 |
| `65c943ff` | 2729 | 11 |
| `1597511d` (the harness moved into `tests/standalone_child.py`) | 2729 | 11 |
| `42a08a3a` (an ignored interrupt is killed before the pipes are joined) | 2730 | 11 |
| `5a26532e` (#59's `814516b7` merged in) | 2753 | 11 |
| `e939c315` (the `stage:` case looks inside every string) | 2753 | 11 |
| `e3574774` (#59's `c2a8ad9f`, with #58's landing, merged in) | 2982 | 11 |
| `38761c76` (the serving line read while running; #59's `183b40fc` merged in) | 2983 | 11 |
| `a6e953ce` (`main` merged in, #59 landed), run under `nohup … &` | 2982, **2 failed** | 11 |
| `149d7295` (the harness resets SIGINT), run under `nohup … &` | 2985 | 11 |
| this head `a7bda066` (the six keys checked as one rendered list), run under `nohup … &` | **2985** | **11** |
At each head, the case-by-case junit diff against the base shows only this file's cases added, **0 removed** and **0 changed** outcomes, and the same 11 skips. Against `38761c76`, this head adds exactly two cases, both passing. One is `test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it`. The other is `test_a_regenerate_promotes_no_session_and_moves_no_active_key`, which #59 gained after `183b40fc`. pyflakes finds nothing in the two new files. No floor, workflow, `pyproject.toml`, `conftest.py` or pin is touched.
## Files
- `src/opendox/cli.py`: `flush=True` on the URL line and on "serving until interrupted" in `cmd_generate_and_open`.
- `src/opendox/serve.py`: `flush=True` on `serve.serve`'s announcement.
- `tests/test_standalone_generate_path.py`: new, the seven cases.
- `tests/standalone_child.py`: new, the child-process harness (a helper module, no case). Its `sitecustomize` refuses the siblings and sets SIGINT back to Python's handler.
Both are created files, with no carve-manifest row (RULED OQ-C).
## For the holder
- **The overlap.** With #59 landed, this PR's edits in `cli.py` and `serve.py` are three `print` calls in two functions. It does not touch `default_registry.py` or `serve_project.py`. But cases 3 and 4 GET `/source/notes-toolshed-inventory.md`, expecting 200 and byte-equal, and `/source/.git/config`, expecting 404, and both requests run through `serve_project` and `default_registry.resolve_source`. So the follow-up to #59 on `resolve_source`'s second lookup should run this module.
- **An observation, not changed here.** Standalone, `/capabilities` answers `actions.gate: true`: it is `local_human`, meaning a resolved actor, a real checkout and loopback. I did not check whether any gate route answers without openXdox's contributed verbs.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n 034) (#68)
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Plan 034 **T058** [US2] [oDc], **the post-render validator in the generate verbs** (#1144's 7.2, in part), from `specs/034-opendox-standalone-operation/tasks.md` at openxFactory `main` `91e4685f`:
> It validates the neutral snapshot against T053's schema, read from T057's packaged copy. `--strict` makes a validator that cannot run fatal, and `--no-validate` skips validation.
> - **Realizes**: 7.2 (part).
> - **Falsifier**: F7.2. The good fixture exits 0; the malformed one exits non-zero, naming `EXPECTED_RULE`, with no `No such file or directory`.
> - **Ruled**: R1Q22 (a), `5817152735`; R1Q11 (a), R1Q12 (a), `5850003126`.
> - **After**: T051, T056, T057.
Claimed on openxFactory#656 in comment `5901343950`, with T056.
## Was stacked on #66, and is now on `main`, with its predecessors landed
- **It was stacked on T056's branch** (#66), which was itself stacked on #59 (T055). Each new #66 head was merged in here, the last being `a7bda066` at `923f30d8`.
- **#66 has landed** as `a23e4224`, after #70 (`75bd8703`, the `resolve_source` follow-up to #59). `main` is merged in at `5322efee`. It brought only #70's three files, `default_registry.py`, `serve_project.py` and `tests/test_edit_action_one_entry.py`, and T058 touches none of them.
- **The merge carries exactly T058's delta.** The stable patch-id of `git diff a7bda06923f30d` equals the patch-id of `git diff main 5322efe`: `f302a8aa` both times. The same id held at `cd6b33cb` against `38761c76`.
- **#58 (T057) was merged in**, at `e94ab323`, `3351f6a7` and `753ffa19`, because T058 wires the `opendox.validator` that #58 ships. **#58 has since landed** as `8ec08e91`, which reaches this branch through #59 and #66 (`03825eb5`, no content change: T057's files here equal `main`'s byte for byte).
- So this PR's diff is now **T058's own seven files**. Until #58 landed, it also showed #58's delta.
- **T058's own changes are six commits**: `3d0b6d66` (six files, listed below), and `28241a4b`, `69ca0e2e`, `1ec7d2c9`, `c7768ed5` and `cd6b33cb` (Copilot's findings, below).
- #58 and #59 fork from #57 at the same commit, `8e7da4a2`, so the merge was clean and brought only #58's own 55 files.
- **Gated on nothing but its own review.** #57 (`a691e4e4`), #58 (`8ec08e91`), #59 (`fa140875`) and #66 (`a23e4224`) have all landed.
- **The base is `main`.** It was retargeted before #66 landed, since a `--delete-branch` landing of #66 would have closed it. The diff against `main` is T058's own seven files again.
## What T058 changes
**`src/opendox/default_projection.py`: openDox's own validator takes the stand-in's place.** The stand-in, `OwnValidatorNotBuilt`, answered every validation "unavailable, T057 is not here".
- **One adapter per own kind.** `OwnValidator(kind)` keeps the lookup's protocol, `validate(path, *, strict, search_from)` → `projection_seams.ValidationResult`.
- The seam registers one validator per kind and hands it only a path. So the adapter knows its kind from where it is registered.
- `VALIDATORS` holds one for each of `OWN_KINDS`: `opendox-snapshot` and `ideation-workbench`.
- `projection_seams.register_defaults()` registers each under its kind (a one-line change).
- `OWN_KINDS` is not widened. The doxBench wire kinds reach `opendox.validator` through their own seam (T085).
- **It reads the document as its kind is written.**
- The snapshot is read as JSON, strictly: NaN, the infinities and a key given twice are refused.
- The manifest is read as YAML with `safe_load`, as `workbench.py` reads it.
- Then `opendox.validator.validator_for(kind)` judges the document against the packaged copy, which `opendox.contracts` proves against its recorded digest on every call.
- **Three outcomes**, which `cli._validate` already turns into consequences:
| the adapter meets | outcome | what the verb does |
|---|---|---|
| no violation | `validated` | prints `validation: opendox-snapshot: 0 violations, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)`, exit 0 |
| any violation | `not-conformant`, rc 1; stdout is one `[<rule>] <where>: <detail>` line per violation, then a count | "validation FAILED … This is the SNAPSHOT", relays the lines on **stderr**, exit 1 (with or without `--strict`) |
| a document that cannot be read as JSON (or YAML) | `not-conformant`, rule `document-syntax` | as above |
| a readable document holding a number that cannot be read as written (an infinity, a NaN, one binary64 would round, or an unprovable spelling) | `not-conformant`, rule `document-number` | as above |
| `ValidatorUnavailable` (a copy failing its identity check, or not evaluable), `UnknownKind`, or a document that cannot be read | `validator-unavailable`, with the reason | "validation SKIPPED … could not run: <reason>", exit 0; **exit 1 under `--strict`** |
- **`strict` and `search_from` change nothing** in the adapter. openDox's validator has no warnings to harden, and it searches for nothing, since its schemas are package data. `dependency_remedy` is `None`: no subprocess runs.
- So F7.2's "no `No such file or directory`" holds by construction.
- `--strict` still means what the verb's help says, through `cli._validate`.
**The workbench manifest's two validator rules, carried (for the holder).**
- The manifest schema says of two rules that it cannot state them, and leaves them to the validator:
- every `recipe.pinned` keyword is also in `recipe.checked`;
- no `recipe.new_candidates` document is already a member or excluded.
- The consumer's script (`validate-ideation-dashboard-contracts.py`, `check_workbench_rules`) checked both. `opendox.validator` checks the schema only, as #58's body says.
- The holder decided (2026-09-28, "To T055: carry two workbench rules") that routing `validate_manifest` to openDox's validator must not drop them. T055 routed it to the stand-in, so they fall due here.
- The adapter carries them for `ideation-workbench`, under the script's identifiers, `workbench-pinned-not-checked` and `workbench-candidate-overlap`. They are judged beside the schema, and are total over any shape.
- If the holder prefers them in `opendox.validator` itself, that is a move within #58's module, and the ids stay.
**The schema copy.**
- `tests/test_neutral_projection.py` reads the neutral contract from the packaged copy, through `opendox.contracts.verified_bytes("opendox-snapshot")`.
- T054's `tests/fixtures/opendox-snapshot.schema.yaml` and its `SCHEMA_SHA256` are removed. The bytes were identical (`f9e3e111…584a` both), so no case's verdict moved.
- The digest case now asserts that the bytes read are the recorded ones, that a strict JSON read equals `contracts.load()`'s YAML read, and that the tree carries one copy.
**The stand-in's cases in `tests/test_projection_seams.py`**, as T055's hand-off listed them:
| before | after |
|---|---|
| `test_the_validator_stand_in_concludes_nothing_and_names_T057` | `test_openDoxs_own_validator_is_bound_to_each_own_kind` |
| `test_openDoxs_own_kind_meets_the_stand_in_and_strict_makes_it_fatal` | `test_openDoxs_own_kind_meets_openDoxs_own_validator` (a bare snapshot is now REJECTED, naming `[envelope-keys]`), and `test_openDoxs_own_validator_unavailable_is_skipped_and_strict_makes_it_fatal` (a copy refused by `opendox.contracts`: skipped, then fatal under `--strict`) |
| the stand-in half of `test_a_manifest_is_validated_by_the_validator_for_its_kind` | a bare manifest is now judged, naming `[required]` |
Two identity asserts also move from `default_projection.VALIDATOR` to `VALIDATORS[kind]`.
**`tests/test_post_render_validator.py` (new, 29 cases):**
- F7.2 through `python -m opendox.cli generate --strict`, with the siblings refused by T056's `tests/standalone_child.py`;
- `generate-and-open --no-open --no-serve --strict` over both fixtures;
- `--no-validate`;
- the three outcomes, including five not-JSON documents, an unreadable path, `ValidatorUnavailable`/`SchemaNotEvaluable`, and a copy tampered below `opendox.contracts`;
- `strict`/`search_from` inert;
- each validator reading as its own kind;
- the two workbench rules, their bounded detail, and `workbench.save(validate=True)` keeping a valid manifest and unwinding a broken one.
## F7.2, failing before and passing after
**#1144's F7.2, verbatim**: a fresh venv, `pip install .` (package data on disk, a non-editable install), both fixtures as fresh repositories, `generate --strict` twice, the rule grep, and the negative grep asserted as exit status 1.
- **Before**, at this branch's base (`3b13f141`: #59 + #66 + #58, with the stand-in), it exits **1** on the good fixture's `--strict` run:
```
validation SKIPPED — this snapshot was NOT checked against the pinned schema
the validator registered for kind 'opendox-snapshot' reached no verdict. …
openDox's own validator is plan 034's T057, and this build does not carry it yet, so nothing of openDox's own kinds is checked
--strict was given and it means what it says: a run that COULD NOT be validated FAILS rather than continuing unchecked
```
- **After**, at `cd6b33cb`, and again at `5322efee`, after `main` was merged in, it exits **0**. The malformed run's stderr:
```
validation FAILED — the pinned validator REJECTED …/bad.json. This is the SNAPSHOT, not the environment: the validator ran fine and found the data non-conformant.
[title-and-summary-are-text] /documents/1/title: '' is shorter than 1
1 violation(s) of the opendox-snapshot contract, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)
```
`EXPECTED_RULE` is `title-and-summary-are-text`, and no `No such file or directory` appears.
**`tests/test_post_render_validator.py`**: at the base, with the stand-in, **26 failed and 2 passed**. The two that pass hold what T058 does not change: `--no-validate`, and `EXPECTED_RULE` being one of the contract's rules. At `3d0b6d66`, **29 passed**. At this head `1678ccd0`, with Copilot's later rounds, **50 passed**, and also 50 under `--noconftest`.
## Mutation check: 29 of 29 killed, re-run at this head
Each mutant was applied, the named cases were run, and the sources were restored and checked by sha256.
| mutant | killed by |
|---|---|
| M1 violations answer `validated` | 17 cases |
| M2 violations answer `unavailable` | 12 |
| M3 the rule lines are dropped from stdout | 17 |
| M4 `ValidatorUnavailable` read as a pass | 4, including the `--strict` case |
| M5 NaN admitted as JSON | 1 |
| M6 a repeated key admitted | 1 |
| M7 the pinned rule dropped | 4 |
| M8 the overlap rule dropped | 1 |
| M9 the overlap ignores `excluded` | 1 |
| M10 the validator reads the document's `kind`, not its own | 1 |
| M11 the entry points register only the snapshot's validator | 2 |
| M12 the rules compare unhashable entries | 1 |
| M13 an unreadable document reads as valid | 1 |
| M14 `--strict` does not make an unavailable validator fatal (`cli.py`) | 1 |
| M15 `--no-validate` does not skip (`cli.py`) | 4 |
| M16 T054's fixture copy of the schema comes back (a tree mutant) | 1 |
| M17 a rule's detail quotes every name | 1 |
| M18 name membership tested against the list again (quadratic) | the linear-time case, at 17.0 s |
| M19 an `OSError` from the lookup escapes | the unreadable-file case |
| M20 a quoted name is not cut | the long-name case |
| M21 a key given twice picks the last `kind` again (`cli.py`) | the doubled-kind case |
| M22 a non-finite number is read as JSON | the `1e999` and YAML `.inf`/`.nan` cases |
| M23 a rounded number is read as written | the `1.0000000000000001` and `1.5e-400` cases |
| M24 every inexact binary fraction refused (over-strict) | the controls (`0.1`, `2.50`, …) |
| M25 the manifest's floats are read unproved | the four manifest-number cases |
| M26 the JSON read proves no float | the snapshot number cases |
| M27 an unprovable number spelling keeps its float | the two base-60 manifest cases |
| M28 a refused number is reported as a syntax error | the number-rule cases |
| M29 the syntax message names the kind as an adjective again ("a 'opendox-snapshot' document") | the two exact-message syntax cases (6 failed) |
## The repository's own checks
The whole suite ran locally as CI runs it: `CI=true`, `LANG=C.UTF-8`, PostgreSQL 16, `-e ".[runtime,test]"` with the constraints file, at the committed head, with a clean tree.
| tree | passed | skipped |
|---|---|---|
| base `3b13f141` | 2948 | 11 |
| `3d0b6d66` (T058's commit) | 2978 | 11 |
| `09cd1e8a` (#66's `42a08a31` merged in) | 2979 | 11 |
| `28241a4b` (Copilot's two findings) | 2981 | 11 |
| `21e4723f` (#66's `5a26532e` and #58's `3351f6a7` merged in) | 3010 | 11 |
| `69ca0e2e` (#66's `e939c31f` merged in, and Copilot's second round) | 3014 | 11 |
| `80153754` (Copilot's third round, and #58's `753ffa19` merged in) | 3031 | 11 |
| `c7768ed5` (#66's `e3574774` merged in, and Copilot's fourth round) | 3033 | 11 |
| `cd6b33cb` (#66's `38761c76` merged in, and Copilot's fifth round) | 3034 | 11 |
| `923f30d8` (#66's final head `a7bda066` merged in), run under `nohup … &` | 3036 | 11 |
| `5322efee` (`main` merged in, with #66 and #70 landed), run under `nohup … &` | 3044 | 11 |
| this head `1678ccd0` (Copilot's sixth round: the syntax message's wording), run under `nohup … &` | **3044** | **11** |
- The junit diff at `3d0b6d66` shows **+32 added** (29 in the new file, 3 in `test_projection_seams.py`) and **2 removed** (the two stand-in cases above, replaced). At `09cd1e8a` it shows +33: the extra case is #66's own.
- At `923f30d8`, against `cd6b33cb`, it shows **+2**: #66's `test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it` and #59's `test_a_regenerate_promotes_no_session_and_moves_no_active_key`. At `5322efee`, against `923f30d8`, it shows **+8**, all from #70's `tests/test_edit_action_one_entry.py`. At this head, against `5322efee`, it shows 0 added, 0 removed and 0 changed.
- **0 changed** outcomes, and the same 11 skips.
- The mutation check was re-run at `923f30d8` and `5322efee` (28 of 28 killed both times), and at this head, where it is **29 of 29**.
- **F4.1's deferred-reach scan**: 11 at the base and 11 here, the same list. The adapter imports `opendox.validator` and PyYAML when a validation runs, and names no sibling.
- No floor, workflow, `conftest.py`, `pyproject.toml` or pin is touched.
## Files (T058's own commits)
- `src/opendox/default_projection.py`: `OwnValidator`, `VALIDATORS`, `SYNTAX_RULE`, `NUMBER_RULE`, `WORKBENCH_RULES`. The stand-in is removed and the docstring rewritten.
- `src/opendox/projection_seams.py`: `register_defaults()` registers `VALIDATORS[kind]`.
- `src/opendox/cli.py` (`69ca0e2e`): `_written_kind` refuses a key given twice, and `_validate` fails on it.
These seven files are the whole of the PR's diff now that #58 has landed.
- `tests/test_projection_seams.py`: the stand-in cases above.
- `tests/test_neutral_projection.py`: it reads the packaged copy.
- `tests/fixtures/opendox-snapshot.schema.yaml`: removed.
- `tests/test_post_render_validator.py`: new. It is a created file, with no carve-manifest row (RULED OQ-C).
## Copilot
- At `3d0b6d66`, "Needs a closer look", with two findings. Both are fixed in `28241a4b`, answered with evidence, and resolved:
- **r4139734412**: an unreadable packaged record or copy escaped `validator_for()` as a `PermissionError` traceback. `opendox.contracts` converts only a missing file.
- Measured with `copies.yaml` at mode 000: `generate --strict` exited 1 with the traceback.
- The adapter now reports an `OSError` from the lookup as validator-unavailable, and the verb warns, or fails under `--strict`, in its own words.
- The root conversion is #58's file, and it has been relayed to #58's owner.
- New case: `test_a_packaged_file_that_cannot_be_read_is_unavailable_not_a_traceback`.
- **r4139734444**: `_names()` was quadratic, and the schema bounds none of the three lists. Membership is now a set's. New case: `test_the_rules_read_a_long_list_in_linear_time`, which took 17.5 s before the fix and 0.01 s after.
- At `21e4723f`, "Needs a closer look", with four findings, each answered with evidence and resolved:
- **r4139769819**: the verb chose a validator by `kind` with plain `json.loads`, which keeps the last of two keys. So `"kind": "opendox-snapshot", "kind": "unknown"` found no validator, and an ordinary run exited 0.
- Fixed in `69ca0e2e`: `cli._written_kind` refuses a key given twice, and the verb fails whatever `--strict` says. New case: `test_a_kind_given_twice_chooses_no_validator_and_fails`.
- NaN and the infinities are left to the chosen validator, since they do not make the kind ambiguous.
- **r4139840593**: `1e999` reads as `inf` without `parse_constant`. Fixed in `69ca0e2e` (`parse_float=_finite`), with two new not-JSON cases.
- **r4139769791**: a quoted name was not cut. Fixed in `69ca0e2e`: each is cut at 80 characters. New case: `test_a_rules_detail_cuts_a_long_name`.
- **r4139769759** (`validator.py`, #58's file): `_close` consuming earlier siblings' canons at `count == 0`. **Not reproduced**: the slice is `done[len(done) - count:]`, which is empty at 0, and `_canon([1, []])` and `_canon([[], 1])` are distinct (`a2:n1:1a0:`, `a2:a0:n1:1`). No change here; the note went to #58's owner.
- (r4139734412's root, `opendox.contracts` refusing an unreadable file, has since landed in #58 as `2b8ad24`, merged in here. With it, a record at mode 000 gives `could not run: opendox.contracts has copies.yaml, and it cannot be read (PermissionError: Permission denied)` and no traceback. The adapter's own `OSError` guard stays as defense in depth.)
- At `69ca0e2e`, one finding, answered with evidence and resolved:
- **r4139937566**: `float()` rounds `1.0000000000000001` to `1.0`, which meets `const: 1`. A manifest so written read as "0 violations", and jsonschema 4.26 reads the snapshot case the same way.
- The contract has no `number` type, so rather than carry decimals through #58's validator, `1ec7d2c9` refuses a float literal that is not finite, or not equal to the shortest spelling of the float read from it, under `document-syntax`. The same proof applies to the manifest's YAML floats.
- `0.1`, `2.50`, `1E2` and every float openDox's writer writes read as written.
- Six new cases fail without the fix, and seven controls pass either way.
- At `80153754`, one finding, answered with evidence and resolved:
- **r4146201125**: a YAML base-60 float (`0:1.0000000000000001`) is read and rounded by PyYAML, but `Decimal` cannot parse it, so the proof let it through, and the manifest read as "0 violations".
- Fixed in `c7768ed5`: a spelling the proof cannot compare is refused, under `document-syntax`.
- Two new cases fail without the fix, and mutant M27 is killed.
- At `c7768ed5`, one finding, answered with evidence and resolved:
- **r4146428769**: a valid document refused for a number was reported as "cannot be read as YAML/JSON".
- Fixed in `cd6b33cb`: such a number breaks a rule of its own, `document-number`. `document-syntax` stays for a document that cannot be read at all.
- Ten cases fail without the change, and mutant M28 is killed.
- At `cd6b33cb`, "Needs a closer look", with nothing open.
- At `5322efee`, "Needs a closer look", with one finding, answered with evidence and resolved:
- **r4148179148**: `document-syntax`'s message read "a 'opendox-snapshot' document", which takes the wrong article.
- Fixed in `1678ccd0`: it now reads "a document of kind 'opendox-snapshot'".
- The two exact-message cases, updated first, failed against the old wording (6 of 6 runs), and mutant M29 is killed.
- At this head `1678ccd0`, a review is re-requested through the reviewer API.
## For the holder
1. **The two workbench rules** (above): carried in the adapter, under the consumer script's ids. Tell me if they belong in `opendox.validator` instead.
2. **The verb relays at most the last 20 lines** of a rejection (`cli._report_non_conformance`, T055's, unchanged). A snapshot breaking more than 19 rules shows the count line and the last 19. That is enough for F7.2's single rule, and not changed here.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…swers them (R1Q19 (a)) (plan 034) (#65)
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
**T063 (the phase-2 checkpoint) has landed** (openxFactory#1218 to `a883bbf6`), so the condition this draft was authored under is met. It was authored ahead under Brett's phase-3 draft-ahead word, "Install chain + lens (Recommended)" (openxFactory#656 comment [5901112350](opensoft/openxFactory#656 (comment)), 2026-09-29). Claim: [5901192386](opensoft/openxFactory#656 (comment)). The holder posts READY; this PR does not.
## T088 (plan 034, slice P3-L): the lens's two seed actions
The task, from `specs/034-opendox-standalone-operation/tasks.md` at openxFactory `main` `91e4685f`:
> **The lens's two seed actions** are offered only where a binding answers them (R1Q19 (a)). Standalone, no binding answers `/actions/dtn-seed` or `/actions/staging-seed`, so neither control is offered. `lens.js` stays the web census's one declared `?` row. Moving the two controls into a view extension that openxFactory contributes, which retires the row, is R1Q19's (b), for later and outside release 1.
- **Realizes**: none of the 69; this is the precondition for AT-R1 step 6.
- **Falsifier**: AT-R1 step 6 (`spec.md`): "`#tab-lens` renders the bullseye with the corpus's documents as dots, and not the text 'nothing on the radar'. Neither of its two openxFactory seed actions is offered, since no binding answers them (R1Q19 (a))."
- **Ruled**: R1Q22 (a), `5817152735`; R1Q19 (a), `5850003126`. No #1144 line changes (R1Q19 amends none), so there is no batch amendment to carry.
- **After**: T063, T069. Both are done: T069 earlier, and T063 as openxFactory#1218 to `a883bbf6`.
## What changed
`src/opendox/web/views/lens.js`, and the census's own bookkeeping for that file. Nothing else in `src/`.
- **The capability that says a binding answers is not new server code.** `/capabilities` already carries `views.contributed_routes`, which `serve.build_server()` builds from the very `route_bindings` table its POST dispatch consults (`route_extension.match(self.route_bindings, "POST", path)`). The lens reads its answer off the payload the shell has already fetched, so there is no second fetch and no new field, and nothing that can drift from the dispatcher.
- **`bindingAnswers(capabilities, method, path)`** (exported) mirrors `RouteBinding.matches`: method, then path, exact or under a prefix, a GET binding answering HEAD too. It fails closed on anything it cannot read (no payload, the probe's fallback, no `views`), and on the WHOLE manifest, as `manifestRoutes()` does: every entry is first held to what `RouteBinding.__post_init__` accepts (a known method, a pattern rooted at a slash with no query or fragment, a boolean `is_prefix`, a prefix ending in a slash), and one malformed entry leaves every route unanswered (Copilot's round-1 finding, taken in d716ded). Unlike `manifestRoutes()` it never throws, because it gates a control.
- **The register seed** (`draft seed`, a drill row on a set two or more repositories share): `ctx.onSeed` is null unless the DTN route is answered.
- **The staging seed** (`draft staging seed`, the pick bar): `ctx.pickDoc` is null unless the staging route is answered. Every selection site (the matrix's checkbox column and select-all, the clickable dots, the `picked` mark, the pick bar) was already guarded by `ctx.pickDoc`, and `test_the_matrix_selection_is_the_seeds_only_input` says the column "exists to feed ONE action". So the selection goes with the button, and a standalone lens never says "tick documents to draft from them". The matrix then draws no empty gutter (and its empty-row `colspan` follows), and the drill note stops naming a seed that is not there.
- Both controls carry a `data-seed-action` hook (`dtn-seed`, `staging-seed`) for the browser half (T096) to assert absence by, without matching on a label that changes (`re-draft`).
- **Census**: `views/lens.js` `loc` 1495 to 1577 and the `?` class total. The row stays `?` (the two route literals and their `route_ownership_exceptions` entry are untouched). The two `until` lines named "a future ruling"; that ruling has now been made in part, so they name R1Q19 (b).
- `tests/test_display_facet.py`'s lens render test drove the lens with no capability payload and read the pick bar's words. It now asks the lens as a host that answers both routes (5 added lines plus 1 changed). The standalone lens is the new file's.
## Round 2: phase 2 has landed (main `047bb4fa`)
- **Main merged into this branch** as a merge commit (`239ebf8e`, no rebase, no force-push), with no conflict. `lens.js`, the census rows and the `test_display_facet.py` respell were not touched by main, so there was nothing to resolve in them. `bindingAnswers` and `lens.js` are unchanged since `d716dedf`.
- **The standalone half now reads the real thing.** A lone openDox can build and serve since T055, so the new `test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action` generates and serves the `plain-documents` fixture (AT-R1 step 3 (a)) in a fresh process with nothing registered, over HTTP, and hands the lens that serve's own `/capabilities` and `/snapshot.json`. The radar draws one dot per document that declares the checked keyword, and no seed control, pick bar, checkbox, clickable dot or word "seed" is on the page.
- **Batch L (openxFactory#1212) and `actions.gate`.** T084 will turn `actions.gate` false on a standalone serve where no gate route answers (measured at `047bb4fa`: a standalone serve with a git identity says `gate: true` while every gate POST answers 404). This lens reads the routes a binding answers (`views.contributed_routes`), not `actions.gate`, so the answer does not move with T084. The falsifier now holds that, rather than assuming it: the standalone cases run with `actions.gate` ON and OFF (hand-built payloads), the real-serve case runs with and without a git identity (which is what sets the serve's own gate verdict today, so both answers are real), and the bound cases run with it ON and OFF too (a read-only project view has no gate and still offers the seeds). Two new mutants make the lens follow `actions.gate` instead of the binding, one per control, and both are killed.
- The child process drops `GIT_*` and `XF_*` from its environment. The suite exports `XF_GATE_PRINCIPALS` (a roster of several names, which is ambiguous with no claim), and that alone resolved no actor whatever the repository's own identity said.
## The falsifier, before and after
`tests/test_lens_seed_actions.py` (new, 13 cases) drives the REAL `views/lens.js` under node. Payloads are built with the real `route_extension.collect_bindings` and `view_extension.view_manifest`, or, in the real-serve case, read from a real serve. Both vocabularies are read, because the two controls live in different ones.
**Before** (the new file against `main` `047bb4fa`'s `lens.js`): 12 failed, 1 passed. The AT-R1 step 6 cases fail on the behaviour, not on a missing symbol, including the real-serve ones:
```
AssertionError: ('keywords', 'a seed action is offered where no binding answers it')
AssertionError: ('real serve', 'a seed action is offered where no binding answers it')
FAILED ...::test_a_standalone_lens_draws_its_radar_and_offers_neither_seed_action[gate-on]
FAILED ...::test_a_standalone_lens_draws_its_radar_and_offers_neither_seed_action[gate-off]
FAILED ...::test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action[actor]
FAILED ...::test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action[no-actor]
FAILED ...::test_the_same_lens_offers_both_where_a_host_contributes_both_routes[gate-on]
FAILED ...::test_the_same_lens_offers_both_where_a_host_contributes_both_routes[gate-off]
FAILED ...::test_each_seed_action_is_offered_on_its_own_route_alone[contributed0-True-False]
FAILED ...::test_each_seed_action_is_offered_on_its_own_route_alone[contributed1-False-True]
FAILED ...::test_a_payload_that_cannot_say_a_binding_answers_reads_as_none_answering
FAILED ...::test_a_manifest_entry_is_trusted_only_if_the_server_would_have_accepted_it
FAILED ...::test_a_binding_answers_only_the_method_and_the_path_it_declared
FAILED ...::test_the_lens_answers_exactly_what_the_servers_dispatcher_answers
12 failed, 1 passed
```
(The one that passes is the source ratchet that the manifest and the dispatcher name the same table.)
**After** (this branch, `c0a97648`): `13 passed`.
What the cases assert:
1. Standalone, with `actions.gate` on and off: the radar draws its documents as dots, "nothing on the radar" is absent, no seed control is offered by label OR by hook, and no pick bar, checkbox, clickable dot, pick column or word "seed" survives, in both vocabularies.
2. The same, over a real standalone serve of the `plain-documents` fixture, with and without a git identity.
3. A host contributing both routes gets both controls (register seed only on the shared row), with `actions.gate` on and off, so "never offer them" cannot pass.
4. Each control is offered on its own route alone.
5. Fail closed on every unreadable payload shape, including one bad entry beside a good one (either order).
6. Method and path exactness, and prefixes.
7. `bindingAnswers` over the published manifest equals `route_extension.match()` across GET, HEAD and POST and exact and prefix routes.
8. The lens's notion of a well-formed manifest entry is `RouteBinding`'s own: over 21 candidate entries, on both sides of every clause and including JSON shapes that were never constructed, a list holding one beside a good seed route answers yes exactly when `RouteBinding` accepts it.
9. `serve.py` names the same table for the manifest and the POST arm (read as text; the real serve in case 2 now exercises it end to end).
**Mutants killed** (each applied to `lens.js` alone, the new file run, then restored): staging gate removed; register gate removed; fail open on a payload with no manifest; method ignored; prefix and exact inverted; register seed asking the staging route; GET no longer answering HEAD; pick column drawn with no selection; drill note always naming the seed; `is_prefix` boolean check dropped; gate reading the wrong caps object; one bad entry no longer poisoning the list; each of the rooted-pattern, no-query-or-fragment and prefix-ends-in-slash clauses dropped; the methods table widened; the staging seed following `actions.gate`; the register seed following `actions.gate`. 18 of 18 killed, 0 survivors.
## Measured
`LANG=C.UTF-8`, the whole suite, in the foreground: `main` `047bb4fa` 2878 passed, 177 skipped; this branch (`c0a97648`) 2891 passed, 177 skipped. The skip sets are identical node for node. The 177 are the database-backed cases that CI runs against its PostgreSQL service. Neighbours (`test_web_boundary.py`, `test_display_facet.py`, `test_bullseye_widget.py`, `test_lens_labels_at_scale.py`, `test_view_registry.py`) pass unchanged apart from the one respell above.
## Overlap, and what this deliberately does not touch
- Re-checked `gh pr diff --name-only` against every open openDox-code draft (#60 to #64, #67, #69) at this round: none touches `views/lens.js`, the census fixture, `tests/test_display_facet.py` or any file under `web/views/`. #57, #58 and #59 have landed, and the merge was clean. No `serve.py` edit.
- `.github/workflows/validate.yml` is untouched: its pinned floors are `>=` and permit the rise of +13 collected, and re-pinning them is a cross-draft hotspot better done once, with the phase's bookkeeping.
- T096 (the browser half that observes the absence), T087 and T084 are not this PR.
- **Expected follow-on, not a defect:** when T084 (openDox-code#77) merges main after this lands, it changes `test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action`'s `capabilities["actions"]["gate"] is actor` assertion to `is False`, because of batch L's capability honesty (a standalone serve says `gate` false even with a git identity). The lens reads `views.contributed_routes`, not `actions.gate`, so the rest of the case, the dots and the absent seed controls, holds either way, and the gate-on and gate-off standalone cases already run both ways.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plan 034) (#67)
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034 (`specs/034-opendox-standalone-operation/tasks.md`, read at openxFactory `main` `91e4685f`, after T007 batch H landed as openxFactory#1206 → `f99a2097`), phase-3 slice **P3-I, install mode and the bundle**:
- **T070** (#1144's 13.4, 13.5 and 13.6): `OPENDOX_INSTALL_MODE`, and the `--local` flag.
- **Falsifier:** F13.1's refusals, plus a test of the disagreeing pair.
- **After:** T071 (openDox-code#60, landed as `7ff434d9`) and T007 batch H (landed). It was stacked on #60's branch; the holder has retargeted it to `main`, and `25262459` merges main `7ff434d9`, so this diff is T070 alone.
**Ruled:**
- R1Q22 (a), `5817152735`;
- R1Q15 (b), `5850003126` (as batch H's 13.4 addendum reads);
- the phase-3 draft-ahead widening, `5901112350` (*"Install chain + lens (Recommended)"*).
Claimed on openxFactory#656 in [`5901575394`](opensoft/openxFactory#656 (comment)). **Authored ahead as a DRAFT**, which did not go READY before T063 landed and the holder said so. **T063 has landed** (openxFactory#1218 → `a883bbf6`, 2026-10-02 23:37:43Z), which closes phase 2, so phase 3 is open, and #60 ahead of it has landed.
## What it does
- **The selector** (13.4): `OPENDOX_INSTALL_MODE`, values `local` and `hosted`, defaulting to `hosted`. It is a `SETTINGS` entry read in `runtime/config.py`, placed immediately beside `OPENDOX_OIDC_ISSUER`. A blank value reads as unset, which means hosted: *"It is UNSET, not `local`, that must be safe."*
- **The flag** (R1Q15 (b)): `generate-and-open --local` selects local exactly as `OPENDOX_INSTALL_MODE=local` does. With neither, the install is hosted (13.5). The flag belongs to the verb and follows it (10.1).
- **A flag and a setting that disagree are refused, naming both.** Example: `--local` beside `OPENDOX_INSTALL_MODE=hosted`. No explicit selection is silently overridden. **This is plan 034's fail-closed reading (Principle VII), not #1144's text.** No answer rules the pair, batch H does not write it into #1144, and `evidence/analyze-round-2.md` (U2-1, V2-6) records it for Brett.
- **Local needs no broker.** `RuntimeSettings.oidc_issuer` and `oidc_audience` are empty, and `oidc_jwks_url` is `None`. `jwks_url()` and `discovery_url()` return `""` rather than a path glued onto nothing.
- **Local binds loopback only, with no opt-in** (13.4). A non-loopback `--host` on `generate-and-open`, or `OPENDOX_BIND_HOST` for the runtime's own listener, is refused, naming the loopback rule.
- The set is `serve.py`'s own `LOOPBACK_HOSTS` (`127.0.0.1`, `::1`, `localhost`). 13.4 asks for *"the same judgement at the mode's own boundary"*.
- `config` cannot import `serve`, so it spells the set, and a test holds the two equal. `127.0.0.2` is therefore refused, because the document server does not treat it as loopback either.
- **Hosted, or unset, with no issuer refuses, naming `OPENDOX_OIDC_ISSUER`** (13.5).
- `generate-and-open` asks for the issuer first (`require_the_hosted_issuer`), and then loads the whole runtime configuration. The serving process is the one whose settings are the install's (13.4a; R1Q16 (i)).
- So a hosted run with nothing configured names the issuer and `--local`, not the `OPENDOX_DATABASE_URL` that `load_settings` happens to ask for first (plan 034's requirement-13 scenario 2).
- `load_settings` keeps its own order for every verb that relies on it.
- **Hosted is otherwise unchanged** (13.6): same broker, same pinned issuer, same order of refusals, and a hosted bind may still be `0.0.0.0`.
- **The shape is resolved first.** `generate-and-open` resolves it before it scans, mints or binds anything, so each refusal exits 1 at once. That covers F13.1's `test "$rc" -ne 124`.
### Holder readings (coordinator, 2026-09-30, on openxFactory#656; Brett may overrule)
1. **`opendox-runtime runtime serve` refuses under `local`** (`refusal: local-mode-has-no-broker`). Every `/api/v1` route verifies a broker-signed token, and a local install is served by `generate-and-open --local`. In release 1 its document surface reads nothing from the store (R1Q16 (ii)).
2. **`runtime status` under `local`** reports `broker_keys: "not configured (local mode)"` and `broker_discovery: null`. It never builds a verifier, and its exit code is the database's verdict alone, so F13.1's `set -e` survives.
3. **A broker setting beside `local` is refused, naming each one**: `OPENDOX_OIDC_ISSUER`, `OPENDOX_OIDC_AUDIENCE` and `OPENDOX_OIDC_JWKS_URL`. The values are never repeated. T072 adds the two DSNs to the list.
4. **An unrecognised value** (`Local`, `single-user` …) **is refused, naming `local` and `hosted`**, and matching is case-sensitive.
### Deliberately NOT here
- **T070 is the identity half; T072 is the datastore half.** Under `local`, `load_settings` still takes the DSNs from the environment, and `generate-and-open --local` loads no database setting. T072 supplies both DSNs from the bundled server (13.1) and refuses operator DSNs beside `local`.
- T073's `install` block is not here. `serve.py` and `pyproject.toml` are untouched.
### Outside `src/` and `tests/`
- **`deploy/` — one line, and the task requires it.** `deploy/compose/.env.example` gains `OPENDOX_INSTALL_MODE=hosted` with its comment. `config.py`'s header makes `.env.example` the place every setting is named, and `tests_runtime/test_deploy_shape.py::test_every_runtime_setting_is_documented_in_env_example` is parametrized over `SETTINGS`, so a new setting without the line is red. Neither the compose file nor the Kubernetes base changes: the default is already hosted.
- **`docs/`: untouched.** 10.3's README line belongs to T076, in the openDox root.
- **One existing test changes, `tests/test_doxbench_entrypoint.py`'s fixture.** It drives `cmd_generate_and_open` with no settings, and the unset default is now hosted, which refuses without an issuer. So it passes `--local` and scrubs the runtime settings first.
## The falsifier
**F13.1's refusal probes**, verbatim from `# LOCAL mode REFUSES a non-loopback bind` to the end of the block, plus T070's own disagreeing pair. Two deviations are forced by the base, and neither weakens a check:
- the install is the working venv's `-e ".[runtime,test]"`;
- the corpus is a two-file stand-in, because `tests/fixtures/plain-documents` arrives with T050 (#53), which this stack's base (`main` `2d116415`) predates.
Each probe on its own. BEFORE is #60's head `f097fd8`; AFTER is this branch:
```
=== BEFORE (f097fd8)
FAIL local-refuses-non-loopback-bind (rc=1): Traceback (most recent call last): File ".../src/opendox/consumer_reach.py" …
FAIL hosted-no-issuer-names-it (rc=1): Traceback (most recent call last): …
FAIL unset-default-refuses-identically (rc=1): Traceback (most recent call last): …
FAIL disagreeing-flag-and-setting-names-both (rc=2): usage: ideation-dashboard [-h] … (argparse: no --local)
=== AFTER (this branch)
PASS local-refuses-non-loopback-bind (rc=1)
PASS hosted-no-issuer-names-it (rc=1)
PASS unset-default-refuses-identically (rc=1)
PASS disagreeing-flag-and-setting-names-both (rc=1)
```
The whole sequence under `set -euo pipefail` (F13.1's own form), AFTER:
```
probe 1: local refuses a non-loopback bind
rc=1; stderr: generate-and-open refused: --host '0.0.0.0' is not a loopback address, and a LOCAL install binds LOOPBACK ONLY (127.0.0.1, ::1, localhost). … There is no opt-in: …
probe 2: load_settings (T071's block, unchanged)
probe 3: hosted with no issuer refuses naming it
rc=1; stderr: generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED …
probe 4: the unset default refuses identically
rc=1; stderr: generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED (OPENDOX_INSTALL_MODE is unset, and unset means hosted) …
probe 5 (T070's own, not F13.1's): a disagreeing flag and setting are refused naming both
rc=1; stderr: generate-and-open refused: --local selects the LOCAL install and OPENDOX_INSTALL_MODE=hosted selects the HOSTED one. …
F13.1 REFUSALS + T070 PAIR: ALL PASSED
```
In the suite, the same probes run as bounded child processes of `python -m opendox.cli` (`timeout=30`, and a `TimeoutExpired` fails as "a server that STARTED") in `tests/test_install_mode_entrypoint.py`. The loader-level cases are in `tests_runtime/test_install_mode.py`.
## A mutant of each new refusal, killed
Each mutant was applied alone and the three T070 test files run with `-x`, with a 240 s bound so a hang could not pass for a kill:
| mutant | killed by |
|---|---|
| M1 the disagreeing pair accepted (flag wins) | `test_a_flag_and_a_setting_that_disagree_are_refused_naming_both` |
| M2 an unknown value read as hosted | `test_an_unrecognised_value_is_refused_naming_the_two[Local]` |
| M3 unknown values matched case-insensitively | same, `[Local]` |
| M4 the default flipped to local (the safety) | `test_the_selector_has_two_values_and_its_default_is_hosted` |
| M5 the hosted issuer-first check a no-op | `test_a_hosted_install_with_no_issuer_refuses_naming_it[None]` |
| M6 the local loopback refusal a no-op | `test_a_local_install_refuses_a_non_loopback_bind_naming_the_rule[0.0.0.0]` |
| M7 broker settings beside local accepted | `test_a_broker_setting_beside_the_local_mode_is_refused_by_name[OPENDOX_OIDC_ISSUER]` |
| M8 the local bind set widened (`127.0.0.2`) | `test_a_local_install_refuses_a_non_loopback_bind_naming_the_rule[127.0.0.2]` |
| M9 local still requires the issuer | `test_a_local_install_needs_no_broker[setting]` |
| M10 `runtime serve` serves under local | `test_runtime_serve_refuses_under_the_local_mode` |
| M11 `runtime status` probes a broker under local | `test_runtime_status_under_the_local_mode_probes_no_broker` |
| M12 `generate-and-open` ignores `--local` | `test_the_flag_refuses_a_non_loopback_bind_exactly_as_the_setting_does` |
| M13 `generate-and-open` skips the install shape | `test_local_mode_refuses_a_non_loopback_bind_naming_the_rule` |
M10 at first HUNG rather than failed: the un-stubbed case started a real uvicorn listener. That case now stubs uvicorn and the app, so the mutant fails at once. The table above is the re-run.
## Fix round: the fixture's repository ignores the user's git config (`32683e8`)
Copilot's review at `b50e3b1` ([finding](#67 (comment))) was real. `tests/test_install_mode_entrypoint.py`'s `corpus` fixture ran `git commit` under the caller's global git configuration, so a global `commit.gpgsign=true` failed the setup before any probe ran.
It now sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, as `tests/test_checkout_head.py` does. Measured with a hostile global config (`commit.gpgsign = true`, `gpg.program = /bin/false`):
- at `b50e3b1`: `7 passed, 7 errors`;
- at `32683e8`: `14 passed`.
[Replied](#67 (comment)). The probes are unchanged.
## Fix round 2: `runtime migrate` and `reset` refuse what a local install cannot be (`525f61c`)
Copilot's second review, at `32683e8`, raised two points in its overview, with no inline thread. Both are [answered on the PR](#67 (comment)).
1. **Real, and fixed.** `load_migration_settings` recorded `local` but never asked `refuse_what_a_local_install_cannot_be`. So `runtime migrate` and a confirmed `runtime reset` accepted a broker setting, or a non-loopback `OPENDOX_BIND_HOST`, beside `OPENDOX_INSTALL_MODE=local`. They now refuse at configuration, before any database is reached.
- Seven new cases in `tests_runtime/test_install_mode.py`: the three broker settings × {`migrate`, `reset --confirm …`}, plus the bind.
- Against `32683e8`'s config they give `7 failed`; here they pass.
2. **Pre-existing, and not changed here: `::1` passes the loopback rule, but the server cannot bind it.** `serve.build_server` is IPv4-only (`ThreadingHTTPServer`), while `serve.LOOPBACK_HOSTS` lists `::1`.
- Measured at #60's head `f097fd8`, before this PR: `127.0.0.1` binds, and `::1` fails with `gaierror [Errno -9] Address family for hostname not supported`.
- This PR's rule is `serve.LOOPBACK_HOSTS` itself (13.4's *"same judgement"*). So `::1` is classified exactly as the document server already classifies it, and then fails at the bind exactly as it does in hosted mode today.
- The fix is a `serve.py` change (address family, and `server_url`'s brackets), outside T070's file set. It is listed below for the holder.
## Fix round 3: `status` reports a local install's broker on its early return too (`02dadc5`)
Copilot's review at `525f61c` raised one point in its overview, with no inline thread. It is real, and it is [answered on the PR](#67 (comment)).
- With the runtime extra absent, `runtime status` returns early, and that return said `broker_keys: "not probed"` for every install.
- Local now gets the full report's answer there: `"not configured (local mode)"`, with `broker_discovery: null`. Both returns write it through one helper.
- Hosted still says `"not probed"` (13.6).
- The new case runs with `opendox.runtime.db` absent from `sys.modules`. `[local]` fails against `525f61c`'s `runtime/cli.py` and passes here; `[hosted]` passes in both.
- Four mutants of the fix are killed.
## Fix round 4: a healthy local `status` is proven to exit 0; `RuntimeSettings`' invariants are scoped (`859b37b6`)
Copilot's review at `02dadc55` opened two threads, both real. Both are answered and resolved.
- [r4139922962](#67 (comment)): both local `status` cases forced a database fault, so nothing proved exit 0.
- A DB-backed case now runs local `status` against a migrated schema on the suite's server and asserts `ok`, exit 0, and a broker that is not configured and never probed.
- With the local return mutated to `ok=False`, the new case fails and the module's other 40 pass. Before this commit, that mutant survived.
- [r4139922999](#67 (comment)): the docstring's broker statements hold for `load_settings` only, so they are now scoped to their loader. `load_migration_settings` carries the migration sentinels in either shape. This is documentation only.
## The repo's own suite
Full `python -m pytest -q`, `LANG=C.UTF-8`, `CI=true`, against a `postgres:16` like `validate.yml`'s:
| | selected | passed | skipped | failed | errors |
|---|---|---|---|---|---|
| #60's head `f097fd8` | 2485 | 2474 | 11 | 0 | 0 |
| `b50e3b1` (T070) | 2531 | 2520 | 11 | 0 | 0 |
| `525f61c` (fix round 2) | 2538 | 2527 | 11 | 0 | 0 |
| `02dadc5` (fix round 3) | 2540 | 2529 | 11 | 0 | 0 |
| `859b37b6` (fix round 4) | 2541 | 2530 | 11 | 0 | 0 |
| `3185e7d` (merges #60's `adeb6fed`, which carries main `047bb4fa`), before the callers' edit | 3117 | 3102 | 11 | **4** | 0 |
| `c8fac05e` (the four callers say `--local`) | 3117 | 3106 | 11 | 0 | 0 |
| `cdf7382b` (merges #60's `c39d960e`; the callers inherit no runtime setting) | 3126 | 3115 | 11 | 0 | 0 |
| `d1de1fd9` (the healthy-local status case sets its schema with `make_conninfo`) | 3126 | 3115 | 11 | 0 | 0 |
| `105f2f12` (no exported runtime setting reaches a `tests_runtime` case), clean and with `OPENDOX_INSTALL_MODE=local` exported | 3126 | 3115 | 11 | 0 | 0 |
| this branch, `25262459` (merges main `7ff434d9`: #60, #65 and #71; #71's generate-and-open child gains `--local`) | 3185 | 3174 | 11 | 0 | 0 |
`026f00ea` (fix round 5) is a docstring change. The install-mode module now names its one DB-backed case ([r4146171212](#67 (comment)), resolved), and the module runs 41 passed.
That is +56 cases: this PR's two new files, plus the `.env.example` census's new parameter (+46 at `b50e3b1`), fix round 2's seven, fix round 3's two, and fix round 4's one. `EXPECT_SKIPPED=11` holds exactly, and the floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`) allow the rise unchanged.
## Merge-from-main round, and what T070 owed once T056 landed (`3185e7d`, `c8fac05e`; 2026-10-02)
Phase 2 has landed. #60 (T071) merged main `047bb4fa` as `adeb6fed`, and `3185e7d` merges that head here.
- **The merge itself.** Git auto-merges `src/opendox/cli.py` and `tests/test_doxbench_entrypoint.py` without a conflict. Main's `_refuse_empty_source_options` sits after the install shape is resolved, and `--local` still precedes `--host`.
- **The edits T070 owed.** Since this PR, an unflagged `generate-and-open` is HOSTED and refuses without its broker's issuer. Four cases that landed with phase 2 relied on the old default, and all four failed on the merged tree with that refusal. Each runs the single-user install, so each now passes `--local` (`c8fac05e`). None means hosted, so none takes a hosted fixture.
1. **`tests/test_standalone_generate_path.py`** (T056), case 3, `test_generate_and_open_starts_a_server_that_answers_with_no_sibling`. Its module docstring now names the change and moves F10.1's plain-install run to T077.
2. **`tests/test_post_render_validator.py`** (T058), `test_generate_and_open_gives_the_same_verdicts`, both fixtures.
3. **`tests/test_projection_seams.py`** (T055), `test_generate_and_open_refuses_an_empty_source_option_before_its_run_dir`.
- **The stand-ins in `tests_runtime/local_entrypoint_driver.py`** go too, but that driver is T072's file and does not exist on this branch. Its edit is named in #69's merge round.
- **Mutants** of the local path, each failing all four cases: `--local` ignored; local refusing its own loopback default; local also asking for the hosted issuer.
- **Full suite:** `3117 selected, 3106 passed, 11 skipped, 0 failed`.
## Fix round: the `--local` callers inherit none of the runner's runtime settings (`9ae5e72`, `cdf7382b`)
`9ae5e72` merges #60's fix round `c39d960e` (a PostgreSQL scheme libpq would not read as a URI is refused), cleanly.
Copilot's review at `c8fac05e` opened three threads, all real and all now answered and resolved. A `--local` caller took the runner's environment along, so an exported `OPENDOX_INSTALL_MODE=hosted` or broker issuer made it refuse before it reached what it tests.
- **`tests/standalone_child.py`:** every child's environment drops each name in `opendox.runtime.config.SETTING_NAMES`.
- **`tests/test_projection_seams.py`:** the in-process empty-option case scrubs them first, as the doxBench entrypoint fixture does.
- **A harness case** exports a hosted install's four settings and asserts that a child sees none of them.
Evidence:
- Under exported hosted settings, all 5 cases fail against `9ae5e72` and pass here.
- Both mutants are killed.
- Full suite: `3126 selected, 3115 passed, 11 skipped, 0 failed`.
## Fix round: the healthy-local status case reads either DSN form (`d1de1fd9`)
Copilot's review at `cdf7382b` opened one thread, which is real and is now answered and resolved.
`OPENDOX_TEST_DATABASE_URL` may be libpq's keyword/value form as well as a URI. `test_runtime_status_of_a_healthy_local_install_exits_zero` appended `?options=…` to it. On the keyword/value form that suffix becomes part of `dbname`, so the case failed while the fixtures connected (measured). The case now sets `options` with `psycopg.conninfo.make_conninfo`, which works on either form. The runtime's `schema_selected_by` reads the schema back out of the result on both forms.
Evidence:
- **Both forms:** `tests_runtime/test_install_mode.py` passes whole under each.
- **Mutant:** dropping the `search_path` option is killed on both forms.
- **Full suite:** `3126 selected, 3115 passed, 11 skipped, 0 failed`.
The same suffix spelling already exists in main's `tests_runtime/test_migrations_apply.py`. It is not this PR's code, so it is left out of scope.
## Fix round: no runtime setting the runner exports reaches a `tests_runtime` case (`105f2f12`)
Copilot's review at `d1de1fd9` opened one thread, which is real and is now answered and resolved. Since this PR the runtime reads `OPENDOX_INSTALL_MODE`. A runner that exported `local` made every hosted case that leaves it unset a configuration refusal: 20 cases, measured. `tests_runtime/conftest.py` gains an autouse fixture that clears every `SETTING_NAMES` entry before each case; `OPENDOX_TEST_DATABASE_URL` is kept, and the production refusal is unchanged.
Evidence:
- **Full suite**, with a local mode and a broker issuer and audience exported and in a clean environment alike: `3126 selected, 3115 passed, 11 skipped, 0 failed`.
- **Mutant:** a scrub that clears nothing is killed.
## Merge of main after #60 landed (`25262459`; 2026-10-02)
#60 (T071) landed as `7ff434d9`, after #65 (T088) and #71 (T085). `25262459` merges that main.
- **`src/opendox/runtime/config.py`'s four conflicts.** Main's side of each is #60's squash, whose `config.py` is byte-identical to `c39d960e`, which this branch already carried (`git diff c39d960 origin/main` on that file is empty). So each resolves to this branch's side: T071 plus T070's install mode.
- **One edit inside the merge commit, because of T070.** #71's `tests/test_doxbench_defaults.py::test_the_served_catalog_route_answers_from_generate_and_open` runs `python -m opendox.cli generate-and-open` as a child. Unflagged, that is now HOSTED, and with no issuer it refused: `generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED`. It now passes `--local`, the single-user install the case means, with a comment saying why.
- **#65's real-serve lens test needs nothing.** `tests/test_lens_seed_actions.py` runs `cli.main(["generate", …])` and `serve.build_server`, never `generate-and-open`, so T070 does not reach it. It passes unchanged.
- **#74 is not on main yet.** Its `tests/test_chat_model_configuration.py` fixtures will need the same `--local` when it lands after this PR.
- **Full suite:** `3185 selected, 3174 passed, 11 skipped, 0 failed`.
## Downstream, for the holder
- **`serve.py` cannot bind `::1`** (pre-existing, measured at `f097fd8`; fix round 2, item 2). `LOOPBACK_HOSTS` lists `::1`, so `--local --host ::1` passes the loopback rule and then fails at the bind with `gaierror`, as `--host ::1` already does in hosted mode. The fix makes the serve path IPv6-aware, which is a follow-up in `serve.py`'s single-writer order. Until then, `::1` could instead be dropped from `LOOPBACK_HOSTS`. That is the holder's call; this PR does neither.
- **The help-tree golden in openxFactory changes at the phase-3 pin.** `tests/ideation-dashboard/fixtures/cli-help-tree.golden.txt` (read by `test_extension_point_parity.py`) snapshots `generate-and-open`'s options, and `--local` is a new one. T094's consumer-pin PR regenerates it. openXdox-code's help-tree case (T042/T008) may need the same.
- **T056 landed before T070**, as the holder ordered. Its edits are done and named in the merge round above.
- **Overlap** (`gh pr diff -R opensoft/openDox-code`): `src/opendox/cli.py` is also rewritten by #59 (T055, +260/−112), in `cmd_generate_and_open` and `build_parser`. `cli.py`'s single-writer order is T055 → T058 → T070. #57, #58 and #59 have landed, and this stack took its merge-from-main round after them. #57 and #61–#64 do not touch these files.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ild (plan 034) (#69)
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034 (`specs/034-opendox-standalone-operation/tasks.md`, read at openxFactory `main` `91e4685f`), phase-3 slice **P3-I, install mode and the bundle**:
- **T072** (#1144's 13.1): the bundled PostgreSQL server, as T007 batch H's 13.1 addendum reads (openxFactory#1206 → `f99a2097`).
- **Falsifier:** F13.1's TCP-listener block, which reads the kernel's socket table at run time, and its `runtime status` block.
- **After:** T070 (openDox-code#67, landed as `66ff7257`). It was stacked on #67's branch; the holder has retargeted it to `main`, and `57b7ed8f` merges main `66ff7257`, so this diff is T072 alone.
**Ruled:**
- R1Q22 (a), `5817152735`;
- R1Q16 (i)–(iv), `5850003126`;
- the phase-3 draft-ahead widening, `5901112350`.
Claimed on openxFactory#656 in [`5901575394`](opensoft/openxFactory#656 (comment)). **Authored ahead as a DRAFT**, which did not go READY before T063 landed and the holder said so. **T063 has landed** (openxFactory#1218 → `a883bbf6`, 2026-10-02 23:37:43Z), which closes phase 2, so phase 3 is open. #60 (`7ff434d9`) and #67 (`66ff7257`) ahead of it have landed.
## What it does, by R1Q16's four parts
- **(i) The document server starts it as its own child, and reports it.**
- `opendox generate-and-open --local` (or `OPENDOX_INSTALL_MODE=local`) starts `postgres` as a **direct child** of the process serving the document surface. It uses `subprocess.Popen` and never `pg_ctl`, which would re-parent it.
- It prints the socket, the pid and what it migrated.
- `runtime status` reports `database_bundle` (`data_dir`, `socket_dir`, `pid`). It reads the pid from the server's own `postmaster.pid` and believes it only while a postgres runs there.
- **(ii) Started and migrated, and nothing more.**
- `initdb` runs once per data directory.
- An idempotent bootstrap makes the database and the SERVED role. Its grants are the compose stack's (`init-runtime-role.sh`): CONNECT, USAGE on `public`, and DML on what the owner creates, by default privileges.
- Then `migrations.MigrationRunner` runs as the owner, with the served role and database declared. The run narrows the ledger to SELECT for the served role and verifies its access, exactly as a hosted `runtime migrate` does.
- `runtime migrate` under `local` migrates the bundle too.
- **(iii) It ships as the `opendox[local]` extra**, which is `opendox[runtime]` plus `pixeltable-pgserver>=0.6.0` (RULED, openxFactory#656 `5916000030` item 2). **The `test` extra joins it**, so F9.1's `.[test]` install still runs every case.
- **(iv) It stops with the entry point.**
- SIGTERM is read as the Ctrl-C the serve loop already stops on, followed by a PostgreSQL fast shutdown (then an immediate one, then SIGKILL, each bounded).
- The backstop is `PR_SET_PDEATHSIG` on Linux, so a SIGKILLed entry point still takes its server with it.
- The server runs in its own session, so a terminal's Ctrl-C reaches the entry point, and the stop happens in order.
### 13.1's fixed identity
- **The data and socket directories live under `OPENDOX_STATE_DIR`**, at `<state>/postgres/data` and `<state>/postgres/run`.
- The setting is new. It defaults to `$XDG_STATE_HOME/opendox`, else `~/.local/state/opendox`, and must be absolute, because the server's process and a `runtime status` run from elsewhere must derive the same socket.
- A state directory too long for the kernel's `sun_path` is refused, naming the setting.
- **No TCP listener:** `listen_addresses` is empty, and the socket directory is narrowed to 0700.
- **Peer authentication** (RULED, openxFactory#656 `5916000030` item 3, *"Peer auth + accept (Recommended)"*):
- `initdb` runs with `--auth-local=peer --auth-host=reject`.
- Before every launch the bundle rewrites `pg_hba.conf` and `pg_ident.conf` (atomically, mode 0600). `pg_hba.conf` holds one rule, `local all all peer map=opendox`, plus `host … reject` for IPv4 and IPv6. `pg_ident.conf` maps the running OS user, and nobody else, to `opendox` and `opendox_runtime`.
- The kernel reports the connecting uid, so the DSNs carry no password because there is none. A cluster an older build left as `trust` is put back to peer on its next start.
- **Both DSNs are supplied:** two users (owner `opendox` for migrations, `opendox_runtime` for serving) over the one socket, with `port` spelled so a stray `PGPORT` cannot redirect libpq. They pass T071's three checks for the reason those exist: one dialect, one database, and never one credential in both settings.
- **An operator DSN given beside `local` is refused by name.** It is added to T070's `HOSTED_ONLY_SETTINGS`, a holder reading on openxFactory#656 that Brett may overrule.
- **A second entry point on the same state directory is refused**, naming the running pid. One install's database belongs to one entry point at a time.
### The migrations gap (assigned to T072 by the holder)
- The migrations were not package data. `migrations/` sits at the repository root, and only the image copies it (`WORKDIR /app`), so `pip install "opendox[local]"` run outside a checkout had nothing to apply.
- Now `pyproject.toml`'s `[tool.setuptools.data-files]` maps `migrations/*.sql` into the wheel's data directory (`share/opendox/migrations`). **The root `migrations/` does not move.**
- `config.migrations_dir` resolves an unset `OPENDOX_MIGRATIONS_DIR` in this order:
1. `migrations` wherever the working directory has one (a checkout, or the image's `/app`), which is **today's default, unchanged**;
2. otherwise the copy the installed distribution's `RECORD` lists (`packaged_migrations_dir`);
3. for an editable install, which installs no data files, the source tree's own `migrations/`.
- The canonical digest gate is what proves any copy found is the pinned one.
- `test_a_wheel_install_migrates_its_bundled_server_outside_a_checkout`:
- builds this package's wheel offline (`--no-build-isolation`, `--no-index`);
- installs it under a `--prefix` outside the checkout;
- runs from a directory with **no** `migrations/`, asserting that `opendox` is the wheel's copy and that the migrations dir is under the prefix's `share/opendox`;
- migrates the bundled server there (`applied == ["0001", "0002"]`).
### The server package (RULED, openxFactory#656 `5916000030` item 2: *"pixeltable-pgserver (Recommended)"*)
- **`pixeltable-pgserver` 0.6.0**, the maintained fork of `pgserver`, uploaded 2026-07-14. Apache-2.0, as its dist-info `LICENSE` and OSI classifier say. It carries **PostgreSQL 16.14** under the PostgreSQL License (`initdb --version` and `postgres --version` from `pixeltable_pgserver/pginstall/bin`). It also carries an 18.4 under `pginstall18/`, which this package does not use.
- **Only its binaries are used**, found with `importlib.util.find_spec("pixeltable_pgserver")` without importing it. Its own manager is not used, because:
- it daemonizes through `pg_ctl`, against (i);
- it stops from `atexit`, which SIGTERM never runs, against (iv);
- it may put the socket under the user's runtime directory, opened to 0777, against 13.1.
- **Linkage, re-verified on the installed wheel** (`readelf -d`, `ldd`):
- `postgres` needs `libz`, `libpthread`, `librt`, `libdl`, `libm`, `libc`;
- `initdb` needs those less `libz` and `libdl`, plus the wheel's own vendored `libpq`. That libpq resolves through `RPATH $ORIGIN/../../../pixeltable_pgserver.libs` and itself needs only `libc`, `libm` and `libpthread`;
- the server's loadable modules need `libc`, and one needs the vendored libpq.
So the system libraries are the C library and libz only: no system PostgreSQL, no ICU.
- **Its floor and its size:**
- Wheels exist for **cp310 to cp314**, on Linux x86_64 and aarch64, macOS and Windows.
- The Linux wheels are tagged `manylinux_2_27` and `manylinux_2_28`, so they need **glibc 2.27 or later**. That is the tag's floor. The highest GLIBC symbol any binary or module needs is 2.25, by `objdump -T`.
- Each wheel is about **24.7 MB** (the cp312 x86_64 wheel is 24,704,230 bytes), because it carries two server majors.
- It pulls in `fasteners`, `platformdirs`, `psutil` and `typing-extensions`, which nothing here imports. All four were already pinned.
- **Superseded:** `pgserver` 0.1.4 carried PostgreSQL 16.2 and had no wheel after cp312 (Copilot r4139811507 and r4139811528, both now resolved with this ruling cited). Also rejected: `postgresql-binaries`, which links the system's ICU and untars at first use, and `pgembed`, which is PostgreSQL 17.
### Outside `src/` and `tests/`
- **`pyproject.toml`:** the `local` extra, the `test` extra joining it, `setuptools>=70.1` in `test` (the wheel test's offline build; 70.1 is the first release that builds a wheel with no `wheel` package), and the data-files map. It is a single-writer file (T057 → T072). #58 (T057) adds package data there, and the merge-from-main round takes it.
- **`constraints-cpython312-linux.txt`,** in its own commit as its header asks. It was extended under its own pins in a clean 3.12.3 venv (`psutil==7.2.2`, `platformdirs==4.12.2`, `fasteners==0.20` and `setuptools==84.0.0` new). At `84a6c041` it was re-resolved in a clean environment under the pins less `pgserver`. The one line that moved is `pgserver==0.1.4` → `pixeltable-pgserver==0.6.0`.
- **`deploy/` — one line, and the task requires it.** `deploy/compose/.env.example` gains `OPENDOX_STATE_DIR=`, because `test_every_runtime_setting_is_documented_in_env_example` requires every `SETTINGS` entry there. The compose stack is hosted and never reads it. **`docs/`: untouched.**
- **Not touched:** `serve.py` (T073 adds the `install` block) and `validate.yml`. It already installs `.[runtime,test]`, and `test` now carries `local`.
- **Existing tests changed:**
- `tests/test_doxbench_entrypoint.py` stands the bundle in, with a tripwire. Its cases test the model port, which reads nothing from the store (R1Q16 (ii)).
- T070's `test_install_mode.py` and `test_install_mode_entrypoint.py` stop passing DSNs beside `local`.
- `test_runtime_surface.py` declares `opendox.runtime.bundle` stdlib-only at import, because `opendox.cli` imports it and `opendox --help` runs with no extra installed.
## The falsifier
F13.1's `runtime status` block and its TCP-listener block, verbatim in their assertions (`f13-1-local.sh`), against a server `generate-and-open --local` started **in the background**, with no broker and no operator database, under `set -euo pipefail`.
**Since the merge round (`19e32f0c`), the start is the REAL entry point.** It is `python -m opendox.cli generate-and-open --local`, with the validator on, over T050's `tests/fixtures/plain-documents` copied into a fresh repository, as F13.1's preamble does. The stand-in driver is deleted. One deviation remains, and it does not weaken a check:
- ~~**Generation is stood in.**~~ Retired at `19e32f0c`. Until then the start went through `tests_runtime/local_entrypoint_driver.py`, because this stack's base predated T055/T056's standalone generate.
- **The pid comes from `runtime status`.** The TCP-listener block reads the server's pid from `runtime status`'s `database_bundle`, not from `caps.json`. `/capabilities`' `install` block is T073's, so F13.1's `caps.json` block is T073's to run.
- ~~**The corpus is a one-file stand-in.**~~ Retired at `19e32f0c`: it is T050's fixture now.
BEFORE is #67's head `b50e3b1`; AFTER is this branch:
```
=== BEFORE (b50e3b1)
ready=1
runtime status rc=1
AssertionError: no bundled database answered: None OPENDOX_DATABASE_URL is required and is not set: …
FAIL status-block
AssertionError: the bundle reports no server pid: None
FAIL tcp-listener-block
=== AFTER (this branch, set -euo pipefail, exit 0)
ready=1
runtime status rc=0
PASS status-block
server pid 638810, sockets ['3692911'], TCP LISTEN rows: none
PASS tcp-listener-block
PASS stops-with-entry-point (pid 638810 gone)
--- server stdout:
database /tmp/tmp.v0wydKm5Zm/postgres/run (bundled, pid 638810, migrations applied now: ['0001', '0002'])
```
T070's F13.1 refusal probes and F13.1's `load_settings` block still pass on this branch (`F13.1 REFUSALS + T070 PAIR: ALL PASSED`).
In the suite, `tests_runtime/test_bundled_postgres.py` runs the same two blocks on the same background launch, and adds three checks: the server's `PPid` is the entry point's pid (i), the socket directory is 0700, and SIGTERM ends the entry point with exit 0 and the server gone (iv). Beside that it has a SIGKILL case (the parent-death backstop), the second-server refusal, `runtime migrate` under `local`, the wheel case above, and the layout and refusal cases. **Under `CI` it fails rather than skips** if the server is missing, because `validate.yml` pins `EXPECT_SKIPPED=11` exactly.
## A mutant of each new refusal and guarantee, killed
Each mutant was applied alone, and `test_bundled_postgres.py` plus `test_install_mode.py` were run with `-x`, with a 240 s bound so a hang could not pass for a kill:
| mutant | killed by |
|---|---|
| M1 an operator DSN accepted beside local | `test_migrate_under_the_local_mode_uses_the_bundle_and_refuses_a_dsn` |
| M2 a socket path too long accepted | `test_a_state_dir_too_long_for_a_unix_socket_is_refused_naming_it` |
| M3 a relative state dir accepted | `test_a_relative_state_dir_is_refused_naming_it` |
| M4 the server opens a TCP listener (`listen_addresses=127.0.0.1`) | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` |
| M5 the server is not the entry point's child (`setsid -f`) | same |
| M6 `stop()` a no-op | `test_a_second_entry_point_on_the_same_state_dir_is_refused` |
| M7 no parent-death signal | `test_the_server_stops_even_when_the_entry_point_is_killed_outright` |
| M8 SIGTERM not read as an interrupt | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` |
| M9 started but not migrated | same |
| M10 the served DSN is the owner's (a collapse) | `test_the_two_dsns_are_two_users_over_the_one_socket` |
| M11 no packaged migrations found | `test_a_wheel_install_migrates_its_bundled_server_outside_a_checkout` |
| M12 the socket directory left 0755 | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` |
| M13 a second server on one state dir not refused | `test_a_second_entry_point_on_the_same_state_dir_is_refused` |
**A defect this PR's own test found in itself.** The first cut of the wheel case ran `pip install --prefix` without `--ignore-installed`. pip then read the suite's own editable `opendox` as the installed copy of the same project and **uninstalled it**, emptying the environment the suite runs in (measured: `pip list` lost `opendox` and both console scripts). The flag is now there, with a comment, and the case asserts afterwards that the suite's own `opendox` still resolves.
## The repo's own suite
Full `python -m pytest -q`, `LANG=C.UTF-8`, `CI=true`, against a `postgres:16` like `validate.yml`'s:
| | selected | passed | skipped | failed | errors |
|---|---|---|---|---|---|
| #60's head `f097fd8` | 2485 | 2474 | 11 | 0 | 0 |
| #67 (T070) `b50e3b1` | 2531 | 2520 | 11 | 0 | 0 |
| #67 (T070) `525f61c`, its fix round 2 | 2538 | 2527 | 11 | 0 | 0 |
| this branch before the merge, `c3a70a2` | 2545 | 2534 | 11 | 0 | 0 |
| `32db5d8` (merges `525f61c`) | 2552 | 2541 | 11 | 0 | 0 |
| `ac61596` (merges `02dadc5`) | 2554 | 2543 | 11 | 0 | 0 |
| `28bdccd` (fix round 4, and merges `859b37b6`) | 2580 | 2569 | 11 | 0 | 0 |
| `96b2699f` (fix round 5) | 2584 | 2573 | 11 | 0 | 0 |
| `a0fb7c8d` (merges `026f00ea`; fix round 6) | 2584 | 2573 | 11 | 0 | 0 |
| `0f77d5c1` (fix round 7) | 2595 | 2584 | 11 | 0 | 0 |
| `379fbb14` (fix round 8) | 2601 | 2590 | 11 | 0 | 0 |
| `84a6c041` (the carrier and peer authentication, as ruled) | 2613 | 2602 | 11 | 0 | 0 |
| `f8e6e9e9` (fix round 10) | 2619 | 2608 | 11 | 0 | 0 |
| `fe232fe` (merges #67's `c8fac05e`, carrying main `047bb4fa`), before its edits | — | — | — | 3 (the bundled background cases) | — |
| `19e32f0c` (the merge round's edits) | 3195 | 3184 | 11 | 0 | 0 |
| `6eb0bbdb` (merges #67's `cdf7382b`; the env probe expects the child's own state dir) | 3204 | 3193 | 11 | 0 | 0 |
| `fedfa75d` (fix round 11, `0488f5bd`, then merges #67's `d1de1fd9` with no file change) | 3210 | 3199 | 11 | 0 | 0 |
| `f66e5f82` (fix round 12, `3426c753`, then merges #67's `105f2f12`) | 3215 | 3204 | 11 | 0 | 0 |
| `a9854078` (in-process local cases get their own state dir), with local and broker settings, a runner `OPENDOX_STATE_DIR` and a 90-character `XDG_STATE_HOME` exported | 3215 | 3204 | 11 | 0 | 0 |
| `21bde1af` (the adversarial review's M1, L1, L2, L3 and replication note) | 3236 | 3225 | 11 | 0 | 0 |
| `28e195b9` (fix round 13: the carrier is found as the installed distribution's own files) | 3240 | 3229 | 11 | 0 | 0 |
| `57b7ed8f` (fix round 14, `bf9bcb08`, then merges main `66ff7257`) | 3327 | 3316 | 11 | 0 | 0 |
| `058d96ef` (fix round 15) | 3328 | 3317 | 11 | 0 | 0 |
| `085ba0b0` (merges main `2fc714d2`, #62 T079) | 3346 | 3335 | 11 | 0 | 0 |
| `3ebccb3c` (fix round 16) | 3350 | 3339 | 11 | 0 | 0 |
| `52a2b8dc` (merges main `9a490405`, #74 T081) | 3399 | 3388 | 11 | 0 | 0 |
| `4a9dbe95` (fix round 17) | 3402 | 3391 | 11 | 0 | 0 |
| this branch, `c04690f8` (fix round 18, `8986158e` and `c04690f8`) | 3404 | 3393 | 11 | 0 | 0 |
`EXPECT_SKIPPED=11` holds exactly, and the floors allow the rise unchanged. The new module adds about 32 s to the run (ten cases, each server start about 1.5 s).
**Merging #67's fix rounds.** `95fe16f` merges `32683e8`, and `32db5d8` merges `525f61c`: `runtime migrate` and `reset` refuse what a local install cannot be.
- `525f61c` and this PR both rewrite `load_migration_settings`. The conflict resolves to this PR's structure: under `local`, the refusal is asked first, and only then is the bundle's migration DSN read. T070's reason is carried into the comment.
- The two merged cases set the local shape as this PR defines it, with the mode and the state dir and no operator DSN. Beside `local` a DSN is itself refused here, so a case that set one would have tested the DSN refusal instead of the broker or bind refusal it names.
- A mutant that drops the refusal from the migration loader fails all 7 merged cases.
- `ac61596` merges `02dadc5`, cleanly. With the runtime extra absent, `status`'s early return now reports a local install's broker as not configured. The merged case uses this PR's local shape and also asserts that `database_bundle` is reported on that return: present for local, `null` for hosted.
## Fix rounds 4 and 5: Copilot's twelve threads (`5e52872`, `96b2699f`)
Copilot reviewed `95fe16f`, `32db5d8` and `ac61596a` and opened twelve threads. **Ten are fixed, answered and resolved.** The new cases are in `tests_runtime/test_local_lifecycle.py` (new, hermetic), plus two in `test_bundled_postgres.py`.
- **Migrations** (r4139811473, r4139880241).
- An explicit `OPENDOX_MIGRATIONS_DIR` is used as given.
- Unset, a **local** install uses only its own installation's copy, never the working directory's. That copy is the source tree `__file__` came from first, then the `RECORD` of the distribution that holds the running module. Where there is none, it is refused.
- A **hosted** install's default is unchanged (13.6).
- **The pid** (r4139811555, r4139938402).
- A pid is believed only when `/proc` proves it is an executable named `postgres` running in this data directory. A proven-stale lock is removed before the launch.
- Where there is no `/proc`, nothing is believed, and PostgreSQL's own interlocks stand. The price is a `status` with no pid on macOS and the BSDs, recorded in the thread.
- **initdb** (r4139880213): it runs into an attempt directory that is renamed into place only on success. Abandoned attempts are removed. A non-cluster `data/` is refused and left alone.
- **start()** (r4139880279): every phase is one guarded operation, and every failure is the one named refusal, with its phase and class.
- **Interrupts** (r4139880267): SIGTERM or Ctrl-C anywhere in the local lifecycle is a clean stop, exiting 128 + the signal number. A served run still exits 0.
- **Refusal wording** (r4139880298): broker settings and DSNs are two classes, each with its own reason.
- **State dir** (r4139938444): an unknown `~user`, or no home, refuses naming `OPENDOX_STATE_DIR`.
- **Test helper** (r4139811584): bounded by a selector. With a silent 8 s child and a 1 s deadline, it returned after 1.0 s where the old loop took 8.0 s.
Evidence:
- Against `ac61596`'s source, 17 of round 4's first 18 cases fail; the one that passes is the unchanged hosted default. Round 5's cases fail against `28bdccd`.
- Mutants: 23 of round 4 and 5 of round 5, all killed (`runs/mutants-t072-r4.txt`, `-r5.txt` in the writer's workdir).
**Round 6** (`a0fb7c8d`): Copilot's review at `28bdccd9` opened two more threads.
- The unknown-`_serves` point was already fixed at `96b2699f`; it is answered and resolved.
- The pyproject note named a function that no longer exists. It now names `installation_migrations_dir`, and the packaging case checks every `opendox.runtime.config.<name>` pyproject names.
- `4aed6278` merges T070's `026f00ea`, a docstring change.
**Round 7** (`0f77d5c1`): Copilot's review at `a0fb7c8d` opened three threads, all fixed and resolved. SonarCloud raised one reliability finding.
- **libpq's environment.** Every `PG*` variable is lifted out of `os.environ` for the duration and put back afterwards, around `generate-and-open --local`'s lifecycle and the runtime verbs under `local`. `PGHOSTADDR`, `PGSERVICE` and `PGOPTIONS` can no longer redirect the bundle's connections. The real entry point and `status` are proven with all three set.
- **The socket's path.** The resolved state tree must be this user's own and writable by no one else. Every ancestor must be owned by the user or root, and sticky where others can write it; a group-writable ancestor of the user's own group is allowed. Symlinks inside the tree are refused before any chmod.
- **A relative HOME** is refused for the default state directory.
- **SonarCloud S6466** (reliability): `server_binaries` no longer indexes a list.
- Evidence: 8 new cases fail against `a0fb7c8d`, and 11 mutants are killed.
**Round 8** (`379fbb14`): Copilot's review at `0f77d5c1` opened two threads, both fixed and resolved.
- A group-writable ancestor is refused whatever its group, because a primary group can have other members.
- The configured path is checked as configured, as well as resolved: both chains' ancestors, every link's owner, and no `..` anywhere.
- Evidence: 5 new cases fail against `0f77d5c1`, and 5 mutants are killed.
**Round 9** (`84a6c041`) applies Brett's two rulings, openxFactory#656 [`5916000030`](opensoft/openxFactory#656 (comment)) items 2 and 3: the carrier and peer authentication, as described above.
- **The server confirms it**, in its own views:
- `pg_hba_file_rules` has exactly the one peer rule (with `map=opendox`) and the two rejects;
- `pg_ident_file_mappings` has exactly the two mappings, for this OS user;
- `system_user` is `peer:<os user>` for both roles.
- **The map decides.** The same OS user asking for a role outside the map is refused (`peer authentication failed`). A non-root suite cannot connect as a second OS user, so for that case the map, read back from the server, stands: it names no other user.
- **Evidence:** 13 new cases fail against `379fbb14`. 9 mutants are killed:
- initdb trust;
- no map;
- a trust rule;
- a wildcard system user;
- a third role;
- no re-assertion;
- any name admitted;
- files 0644;
- the old carrier.
The auth mutants are also killed by the real-server cases alone.
**Round 10** (`f8e6e9e9`): Copilot's review at `84a6c041` raised three points, all fixed.
- An existing `postgres/data` joins the tree check, a broken link included (r4147680113, resolved).
- Missing parent directories are created exactly 0700 whatever the umask. `mkdir(parents=True)` under umask 0002 made them group-writable.
- Readiness requires the data directory's lock file to name the launched child, so a racing loser cannot adopt the winner's socket.
- Evidence: 6 new cases fail against `84a6c041`, and 4 mutants are killed.
**SonarCloud S2115, ACCEPTED, as ruled.**
- **Issue:** `AaDvyyOCiqwq-gAw53M3`, python:S2115, "Add password protection to this database", on `src/opendox/runtime/config.py` `DatabaseBundle.dsn`.
- **New status:** `accept` (SonarCloud now reports it `RESOLVED`). Set with the SonarQube tool on openxFactory#656 `5916000030` item 3's authority.
- **Rationale:** the DSN has no password because the server authenticates Unix-socket connections by PEER. The kernel verifies the connecting uid (`SO_PEERCRED`), and `pg_ident.conf` maps only this install's OS user to the two roles. The socket directory is 0700, the server has no TCP listener (`listen_addresses` is empty), and every host connection is rejected. The same rationale is in the DSN's docstring.
- **Gate:** after the change, SonarCloud reports the PR's quality gate `OK` on every condition.
## Merge-from-main round (`fe232fe`, `19e32f0c`; 2026-10-02)
Phase 2 has landed. This branch now carries #67's `c8fac05e`, which carries #60's `adeb6fed` and main `047bb4fa` (T054 to T058, T055's follow-up #70 and T056's standalone test). Git auto-merges `pyproject.toml` (main's validator package data beside this PR's local extra and data files), `src/opendox/cli.py` and `tests/test_doxbench_entrypoint.py` without a conflict. Four edits followed, all in `19e32f0c`:
1. **The stand-ins in `tests_runtime/local_entrypoint_driver.py` go.** The driver is deleted. Its stand-ins patched names T055 has since replaced, so on the merged tree they stood in for nothing, and all three background cases failed: the real corpus-root check refused the stand-in corpus. `test_bundled_postgres.py` now runs the real entry point over T050's fixture, with the validator on.
2. **Every cheap refusal comes before the database start.** Main's T055 added `_refuse_empty_source_options`, so the local path asks it before it builds the bundled server. `tests/test_projection_seams.py`'s empty-option case carries a tripwire bundle, so a regression neither starts a server nor passes.
3. **No child touches the user's state directory.** T070 gave four phase-2 callers `--local`, and here `--local` starts the bundled server, whose `OPENDOX_STATE_DIR` defaults to the user's `~/.local/state/opendox`. `tests/standalone_child.py` now gives every child a fresh, short, private state directory under `/tmp` and removes it when the child stops. Measured before: three children of T056 and T058 initialized a cluster in the (sandboxed) default state home.
4. **T056's case 3 asserts it:** while serving, its bundled server's data directory is under the child's own state directory, and the directory is gone after the stop.
- **Mutants**, all four killed: the refusal dropped; no private state dir; the dir not removed; the fixture not a repository.
- **Full suite:** `3195 selected, 3184 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-child-*`.
## Merge of #67's fix rounds (`94254b18`, `6eb0bbdb`; 2026-10-02)
`94254b18` merges #67's `cdf7382b`, which carries #60's `c39d960e` (a PostgreSQL scheme libpq would not read as a URI is refused). `cdf7382b` itself means a `--local` caller inherits none of the runner's runtime settings. There were two docstring and setup conflicts, and both were resolved by keeping both sides:
- **`tests/standalone_child.py`:** the code merged cleanly in the needed order. The child's environment first drops every `SETTING_NAMES` entry, and only then is `OPENDOX_STATE_DIR` set to the child's own directory.
- **`tests/test_projection_seams.py`:** the empty-option case scrubs the settings and keeps this PR's bundled-server tripwire.
`6eb0bbdb` changes #67's harness probe. On #67 it asserted that a child sees no runtime setting. Here every child is given exactly one, its private state directory. The probe now also exports a runner state directory, and asserts three things:
- the child sees exactly `OPENDOX_STATE_DIR` among the runtime settings;
- its value is the child's own `Child.state_dir`, not the runner's;
- the directory is gone once the child has exited.
Evidence:
- **Mutants**, all four killed, under an exported hosted install's settings:
- the child keeps the runner's settings;
- the scrub runs after the state dir is set;
- the runner's own state dir is passed through;
- the in-process case does not scrub.
- **Exported settings:** the child-driven modules (`tests/test_standalone_generate_path.py`, `tests/test_post_render_validator.py`) pass whole with a hosted install's settings and a runner `OPENDOX_STATE_DIR` exported.
- **Full suite:** `3204 selected, 3193 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-child-*`.
## Fix round 11: nothing is made through a path the tree check would refuse (`0488f5bd`, then `fedfa75d`)
Copilot's review at `19e32f0c` opened one thread, which is real (reproduced) and is now answered and resolved. `_prepare_directories` made the missing `postgres/run` before the tree check judged the path, so a component it refuses had already been written through: another user's link, or a 0777 directory. In a sticky parent such as `/tmp`, another user could also plant the state directory's name between the check and the `mkdir`. The fix:
- **What exists is judged before any write.** `_refuse_an_unsafe_tree(existing_only=True)` runs the link-ownership loop first, so even a broken foreign link is named. The whole tree is judged again afterwards, before the socket directory's `chmod`.
- **Missing components are made by descriptor.** `_make_private_directories` makes each one relative to its parent's descriptor and opens it with `O_NOFOLLOW`. `fstat` must show it is this user's alone before anything is made beneath it. A planted link, non-directory or foreign directory is the named refusal: never followed, never re-moded.
- **No `mkdir`/`chmod` window.** Each component is born 0700 under a umask of 077, and the umask is put back afterwards.
Evidence:
- **New cases:** six, in `tests_runtime/test_local_lifecycle.py`. All fail against `19e32f0c`'s `bundle.py` and pass here.
- **Mutants:** seven, all killed.
- **Full suite:** `3210 selected, 3199 passed, 11 skipped, 0 failed`.
`fedfa75d` merges #67's `d1de1fd9` (its healthy-local status case reads either DSN form). On this branch that case uses the bundled server, so the conflict resolves to this side and changes no file.
## Fix round 12: the auth files are exactly 0600, and the cluster runs on its own files (`3426c753`, then `f66e5f82`)
Copilot's reviews at `6eb0bbdb` and `fedfa75d` opened two threads, both real and now answered and resolved.
- **`write_authentication`'s 0600 was only a creation request.** The umask filtered it, and a stale temporary from an interrupted start kept its own mode or was written through as a link. Now the stale temporary is unlinked, the new one is opened `O_CREAT | O_EXCL | O_NOFOLLOW`, and its descriptor is `fchmod`-ed to exactly 0600 before anything is written. A link raced in after the unlink is a refusal, never followed.
- **A reused cluster's `postgresql.conf` could redirect `data_directory`, `hba_file` and `ident_file`**, for example to an outside `trust` file. The launch now pins all three on the command line, which outranks every configuration file.
Evidence:
- **New cases:** four in `tests_runtime/test_local_lifecycle.py` (umask, stale 0644, stale link, raced link) and one real-cluster case in `tests_runtime/test_bundled_postgres.py`. All but the raced-link case fail against `fedfa75d`.
- **Mutants:** six, all killed.
- **Full suite:** `3215 selected, 3204 passed, 11 skipped, 0 failed`.
`f66e5f82` merges #67's `105f2f12` cleanly. It adds an autouse fixture in `tests_runtime/conftest.py` that clears every runtime setting before each case, so a case wanting `OPENDOX_STATE_DIR` sets its own, as every one here already does.
## The adversarial review of `f66e5f82` (`a9854078` to `21bde1af`; 2026-10-02)
An adversarial review of `f66e5f82` found nothing high. It found one medium, three lows and a note. Each is fixed in its own commit, each with a new case that fails without it and mutants that are killed. One more hermeticity fix came first.
- **`a9854078`: the in-process local cases give themselves a short state directory.** The doxBench entrypoint fixture and the empty-option case in `test_projection_seams.py` scrubbed the settings, and so fell back to the runner's default state directory. When that is too long for a Unix socket, configuration refuses it before the case is reached. Measured with a 90-character `XDG_STATE_HOME`: 4 errors and 1 failure. Each now sets its own short `OPENDOX_STATE_DIR` and removes it afterwards. Nothing is made in it, because the database is stood in. Both mutants are killed.
- **M1, medium (`e214477d`): a comma in the state directory is refused.** PostgreSQL splits `-k` on commas, and libpq splits a decoded `host` on them. The review reproduced sockets in two unchecked directories, one of them 0777, while the checked 0700 directory stayed empty. `config.database_bundle` (every bundle's one derivation) now refuses a `,` from `OPENDOX_STATE_DIR`, `XDG_STATE_HOME` or `HOME`, without repeating the value.
- **L1 (`c4f5df3c`): the carrier is pinned to `pixeltable-pgserver>=0.6.0,<0.7`, and another major is refused by name.** Before an existing cluster is used, the server's own `postgres --version` is asked against its `PG_VERSION`. Another major, or a server that does not say, is a named refusal, before anything is written. 4 mutants are killed.
- **L3 (`b2d80e94`): the directory creation starts from is judged by its descriptor.** `_make_private_directories` now `fstat`-judges the base it opens with `_unsafe_because` before the first `mkdir`. That is the install's own rule for the state directory and below, and the ancestors' rule above it. This makes round 11's rule hold inside the function itself. 2 mutants are killed.
- **L2 (`9e2f3030`): the local verbs judge their socket before connecting to it.** `runtime status`, `migrate` and `reset` connected to whatever answered at the bundle's socket path. Reproduced here: `status` on a 0777 tree whose `run` linked to another bundle's socket reported that bundle's applied migrations and exited 0. The fix:
- `bundle.refusal_before_connecting` asks the start's tree check (now `bundle.refuse_an_unsafe_tree`) of what exists. It then asks for a live server of THIS data directory: its own `postmaster.pid` must name a live `postgres` whose working directory is this data directory, listening at this socket directory.
- **Status's `database` reads `"not probed: <reason>"`** for a local install that fails this check, including one with no server running. It used to read `"unreachable: …"`, found by connecting.
- **`migrate` and `reset` refuse as `local-bundle-unverified`.** Hosted is unchanged.
- The status database block is re-indented under the new branch; `git diff -w` shows only the branch.
- 7 mutants are killed.
- **The note (`21bde1af`): no replication connection, logical or physical. Fixed, not only reworded.** `authentication_files`' docstring said a replication connection is refused. A physical one was refused, but a logical one (`replication=database`) was accepted as `peer:<user>`, and `IDENTIFY_SYSTEM` answered (measured). No `pg_hba.conf` rule can tell it from an ordinary connection. So the launch sets `max_wal_senders=0`, and the docstring now says both kinds are refused by the server. The mutant is killed.
**Full suite:** `3236 selected, 3225 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-*`.
## Fix round 13: the server is found as the installed distribution's own files (`28e195b9`)
Copilot's review at `f66e5f82` opened one thread, which is real and is now answered and resolved. `server_binaries` used `importlib.util.find_spec`, which follows `sys.path`. Under `python -m opendox.cli` that starts with the working directory, and a corpus checkout is where it runs. So a checkout holding an executable `pixeltable_pgserver/pginstall/bin/postgres` was run as the database server.
The carrier is now looked up by distribution name (`importlib.metadata`), on `sys.path` without the working directory. Both binaries must be files its RECORD lists, inside it and executable.
Evidence:
- **New case:** the working directory holds an importable package and a forged `.dist-info`, and the server is not taken from it.
- **Reworked lookup case:** four refusal shapes.
- **Before:** 6 of the 8 fail against `find_spec`.
- **Mutants:** 4 killed, 1 equivalent.
- **Full suite:** `3240 selected, 3229 passed, 11 skipped, 0 failed`.
## Fix round 14: a named platform gate, resolution failures as reasons, no pid behind a refused tree (`bf9bcb08`)
Copilot's review at `21bde1af` opened three threads, all real and now answered and resolved.
- **A named platform gate.** The bundle is a POSIX design, but the carrier ships Windows wheels, where a start failed as an `AttributeError`. `bundle.unsupported_platform()` names the missing primitives (`os.getuid`, `O_DIRECTORY`, `O_NOFOLLOW`, `os.fchmod`, `mkdir` with `dir_fd`, `socket.AF_UNIX`). A start and the local verbs' socket check ask it first.
- **Resolution failures are reasons.** `refusal_before_connecting()` names a symlink loop (`RuntimeError`) and an embedded NUL (`ValueError`) as reasons, beside `OSError`.
- **No pid behind a refused tree.** `bundle.report()` reports a pid only behind a verified tree. A `postgres/data` linked to another live bundle had reported that server's pid.
Evidence:
- **New cases:** three hermetic ones, and a `linked-data` shape with null-pid assertions in the real verbs case.
- **Mutants:** eight, all killed.
## Merge of main after #67 landed (`57b7ed8f`; 2026-10-03)
#67 (T070) landed as a squash, `66ff7257`, after #61 (T078, `8a98e317`). Main thus holds T070's content as one commit this branch's history never saw, and a plain merge against the old base `047bb4fa` conflicted in ten files where both sides carry the same T070 text.
So the merge is computed against the T070 state this branch already held, #67's `105f2f12`: `git merge-tree --write-tree --merge-base=105f2f12 HEAD origin/main`. It is recorded with both parents. The base is sound because `git diff 252624566ff725` (#67's last branch head against its squash) names only #61's three files. Against it the merge is clean:
- **18 files** take main's changes since `105f2f12`: #65 T088, #71 T085, #61 T078, and #67's `--local` in #71's test.
- **17 of them are byte-identical to main's.** `src/opendox/cli.py` is the one merged file: main's two `doxbench_defaults` registrations sit beside this branch's local path.
- **No standalone `generate-and-open` child main brought in lacks `--local`.** #71's case gained it in #67's merge. #65's lens test runs `generate` and `serve.build_server`. #61 runs no entry point. Under T072, #71's `--local` child starts a bundled server in `tests/standalone_child.py`'s private state directory.
- **Full suite:** `3327 selected, 3316 passed, 11 skipped, 0 failed`.
## Fix round 15: on Linux the parent-death signal is armed, or the server is not started (`058d96ef`)
Copilot's review at `57b7ed8f` opened one thread, which is real and is now answered and resolved. `ctypes` reports a failed `prctl()` by returning `-1`, never by raising (a seccomp denial, say), and the child ignored it. A killed entry point could then orphan the server, against R1Q16 (iv).
The fix:
- **A failed `prctl` is a refusal.** The child now raises on a nonzero return. `subprocess` re-raises that as `SubprocessError`, which `_launch` names as the refusal "could not be given its parent-death signal", with no server process left.
- **A Linux C library with no `prctl` is the same refusal**, where it used to fall back silently. Other platforms are unchanged.
Evidence:
- **New case (Linux):** a stand-in `prctl` that returns `-1`. The start refuses by name, and the stand-in server never runs.
- **Mutants:** both killed.
- **Full suite:** `3328 selected, 3317 passed, 11 skipped, 0 failed`.
## Merge of main after #62 landed (`085ba0b0`; 2026-10-03)
`085ba0b0` merges main `2fc714d2` (#62, T079). #62 changes only `doxbench_binding.py`, `doxbench_intake.py`, `doxbench_provider.py` and `test_model_provider_broker.py`, none of which this branch touches, so the merge is clean. It runs no `generate-and-open` child, so it needs no `--local`. Full suite: `3346 selected, 3335 passed, 11 skipped, 0 failed`.
## Fix round 16: a local install's served role and database are the bundle's own (`3ebccb3c`)
Copilot's review at `085ba0b0` opened one thread, which is real and is now answered and resolved. Under `local`, `OPENDOX_RUNTIME_PG_ROLE` could replace the bundle's served role in the settings. The migration run narrows the ledger privileges of exactly that configured role, while the bundle bootstraps `opendox_runtime` with the default DML and its served DSN connects as `opendox_runtime`. So `OPENDOX_RUNTIME_PG_ROLE=pg_read_all_data` left `opendox_runtime` able to rewrite `opendox_schema_migrations`.
`refuse_what_a_local_install_cannot_be` (asked by both loaders and by `generate-and-open --local`) now accepts two settings only unset or naming the bundle's own:
- `OPENDOX_RUNTIME_PG_ROLE` must be `opendox_runtime`;
- `OPENDOX_SERVED_DATABASE` must be `opendox`, since it declares the same identity.
Hosted is unchanged.
Evidence:
- **New case:** both settings, through both loaders.
- **Mutants:** both killed.
- **Full suite:** `3350 selected, 3339 passed, 11 skipped, 0 failed`.
## Merge of main after #74 landed (`52a2b8dc`; 2026-10-03)
`52a2b8dc` merges main `9a490405` (#74, T081), with no overlap. #74's standalone `generate-and-open` fixture already passes `--local`, since it landed after #67. Under T072 that child now starts a bundled server in the harness's private state directory.
- **`tests/test_chat_model_configuration.py`:** 49 passed.
- **Full suite:** `3399 selected, 3388 passed, 11 skipped, 0 failed`.
## Fix round 17: the `/proc` falsifier is gated, and a backslash in the map is pinned literal (`4a9dbe95`)
Copilot's review at `52a2b8dc` opened two threads, both now answered and resolved.
- **The `/proc` falsifier is gated.** `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` reads Linux's `/proc`, so it now skips where `/proc` is absent, as the other `/proc` and PDEATHSIG cases do. On Linux, and in CI, nothing changes.
- **Backslashes are pinned literal, not escaped.** The thread suggested escaping backslashes in `pg_ident.conf`. Measured against the bundled PostgreSQL 16.14, `pg_ident_file_mappings` reads `"DOMAIN\alice"`, `"alice\"` and `"a\\b"` back as exactly those names, without error. The tokenizer treats a backslash specially only at the end of a line, never inside quotes, so escaping would map a different name.
- The code is unchanged.
- A real-server read-back case and a hermetic verbatim assertion pin the behaviour.
- The escaping mutant is killed.
**Full suite:** `3402 selected, 3391 passed, 11 skipped, 0 failed`.
main has since taken #63 (T080, `1130e996`), whose five files (the doxbench model-provider family) do not overlap this branch. The PR stays MERGEABLE, and CI's merge ref includes it.
## Fix round 18: one schema for both DSNs, and an uninspectable live pid fails closed (`8986158e`, `c04690f8`)
Copilot's review at `4a9dbe95` opened two threads. The holder accepted both, and both are now answered and resolved.
- **`8986158e`: both bundle DSNs name `search_path=public`.** Left implicit, PostgreSQL's `"$user", public` let a reused cluster with a schema named `opendox` or `opendox_runtime` split the owner's ledger from the served role's reads.
- **New real-server case:** both role-named schemas are created, then the bundle restarts. Both roles' `current_schema()` is `public`, they read one ledger, nothing is re-applied, and `status` is reachable with nothing pending.
- **Before:** without the pin, the restart fails with `RuntimeAccessMissingError`.
- **Mutants:** 2 killed.
- **`c04690f8`: a live pid `/proc` will not describe is unknown, not "not ours".** `_identity` raises `_Withheld` for anything but a vanished entry, and `_remove_a_proven_stale_lock` keeps the lock and refuses the start by name rather than unlinking a possibly live server's lock. With no `/proc` at all, PostgreSQL still judges.
- **New case:** a live pid whose `/proc` links are withheld. It fails against `4a9dbe95`.
- **Mutants:** 2 killed.
**Full suite:** `3404 selected, 3393 passed, 11 skipped, 0 failed`, run with `LANG=C.UTF-8` and no `GIT_*`/`XF_*`.
## Downstream, for the holder
- **Out of scope, and not T072's: `serve.py` cannot bind `::1`.** This is pre-existing, measured at #60's head `f097fd8`: `gaierror [Errno -9] Address family for hostname not supported`. `serve.LOOPBACK_HOSTS` lists `::1`, but `ThreadingHTTPServer` is IPv4. Recorded on #67 (T070) for a `serve.py` follow-up in that file's single-writer order. This PR does not touch `serve.py`.
- **Done at the merge round:** the driver's stand-ins are gone, and the probe runs the real entry point on `tests/fixtures/plain-documents`.
- **For T073 and T075, which stack on this PR:** a `--local` child built with `tests/standalone_child.py` gets its own state directory automatically. Any other `--local` caller must set `OPENDOX_STATE_DIR` itself.
- **T073** reads `database_bundle` from the server object the entry point keeps (`args.database_bundle`) for `/capabilities`' `install` block.
- **T074** runs F13.1 whole, `caps.json` block included.
- **Overlap** (`gh pr diff -R opensoft/openDox-code`): `pyproject.toml` (#58) and `cli.py` (#59, and the T058 writer after it). They have landed, and this stack took its merge-from-main round after them. `serve.py` is not touched here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plan 034 (
specs/034-opendox-standalone-operation/tasks.md, read at openxFactorymain6b97c601), phase-2 slice P2-V, its one task: T057 (#1144's 7.1, 7.1a, 7.1b and 7.2, with 7.1 as T007's batch G amends it). Claimed on openxFactory#656 in5873510118.Authored ahead, as a phase-2 draft (Brett 2026-09-27: "Start phase 2's independent tasks"). T049 has landed (openxFactory#1204 →
9d2e5bc3), so phase 2 is open. Both steps it waited on are done:f7ee3c76;52005213.e94ab323moves the record andSPEC_COMMITto it (below).#57 (T054) has landed too, as
a691e4e4, andmainis merged here. This PR stays a draft until the holder un-drafts it.5817152735; R1Q12 (a),5850003126.The stack
This PR was stacked on #57 (T054), and is now based on
main: it was retargeted before #57 landed, and #57 landed asa691e4e4. The diff below is T057's alone. #57's head8e7da4a2was merged here as27bcefc, after T049 landed. #57 has since mergedmainfa8862ccas1a603677, which has8e7da4a2's tree.dc3765dd,86647320andfa8862cc. T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's branch carried their final heads' content before they landed.generator_seam.NEUTRAL_SNAPSHOT_KIND, and the in-process checks run openDox's own projection over T050's and T051's fixtures.main. A--delete-branchon T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57 would close this PR. Then mergemainin.What it does
src/opendox/contracts/: the input set (7.1, 7.1b). It holds the four packaged copies, which are openDox's own spec leg's four:ideation-workbench,opendox-snapshot,xfactory-workbench-chat-turnandxfactory-workbench-model-catalog.f7ee3c76, T053 as landed and the spec commit the openDox root pins. That commit carries all four. It has the tree of § 3.4 S6: /source becomes openDox's own fixed core arm (RULED Q4) #16's last head,cd49eb25, where the copies were first taken. Its three older files are the blobs of the root's previous spec pin,8fe8c4c7.copies.yamlis the record: the spec-leg commit, and each copy's path and sha256. A copy is read only after its sha256 is proved against the record. A changed, absent or unpinned copy is refused before a byte of it is parsed (neutral-product-pin's rule for a vendored contract).gate-intentandideation-possibles-registerare not in the set, and neither are the other four schemas of the family. A record that names any copy other than the four, or leaves one out, is refused (COPY_IDS), so no edit to the record lets one in.opendox.contractskey inpyproject.toml(below).src/opendox/validator.py: the validator, new surface at the code leg (7.2).format: date-timeis asserted, as the consumer's validator asserts it.[<x-rule>] <where>: <detail>.validators()answers one validator per wire kind, with the jsonschema-shapediter_errors()that the doxBench seam's readers use, built as openxFactory'sdoxbench_contractsbuilds its own (T085).4610bca5(below).tests/test_validator_input_set.pyis the falsifier (28 cases).tests/test_validator.pytests the evaluator (185 cases).tests/fixtures/spec-examples/is its corpus, openDox-spec's own examples atcd49eb25. It holds 13 positives of three kinds (2opendox-snapshot, 5 chat-turn and 6 model-catalog) and the neutral contract's 32 negatives, one per rule. All 45 are verified as the spec leg's exact blobs.ideation-workbenchexamples are not carried. This leg's committed-manifest guard (workbench.committed_manifests, held bytests/test_workbench.py::test_no_workbench_manifest_is_tracked_in_this_repo) refuses a workbench manifest tracked anywhere but the root'sexamples/andcontracts/schemas/. CI went red on them at97b314a(run36450791192), and69f2040removes them.workbench.Workbenchwrites: one per seed kind, each with every member route, an exclusion, every action and a notebook binding. An override with no recorded reason is refused as[required]at its member. The cross-check below still covers the two examples, read from the spec leg's own tree.No skip is added, since
EXPECT_SKIPPEDis exactly 11. It touches noopenspec/changes/path, noconftest.py, no workflow, noREADME.mdand no pin. Its onepyproject.tomlchange is the package-data key, on the holder's decision (below).The falsifier
T057's falsifier is "the packaged-copy digest test and the 7.1b test". At
80b5f9e:Both go red when they should. In a scratch clone of
ca52182I changed one byte of the neutral copy (byte 10181,itoI) and planted agate-intentcopy beside the four:The two tests' assertions that went red there are unchanged since.
9d2cda1adds one assertion to the 7.1b test:COPY_IDSnames neither schema.The digest test holds each copy to the spec-leg commit the openDox root pins. The test states the root's digests apart from the record, so a copy and its recorded digest cannot move together unseen.
contracts/manifest.yamlrecords at its spec pinf7ee3c76(the root'smainat52005213, dox-v1.1: T053's neutral snapshot contract (spec pin, manifest bump, CHANGELOG draft) openDox#14).8fe8c4c7(the root'smainat663ac683).Read with
git show <commit>:contracts/schemas/<file> | sha256sumin openDox-spec:8fe8c4c7,cd49eb25andf7ee3c7652005213d30438491119…faafc350bfedc0269…1dc1de563cc9fc6ed…216358fe8c4c7;f9e3e111af1d…4a584aatcd49eb25andf7ee3c76The package-data line (on the holder's decision)
7.1 ships the four as package data.
pyproject.tomlused to package onlyweb/**. On the holder's decision, this PR now carries the new key: openDox-code's phase-1 work has landed, so no phase-1 writer editspyproject.tomlany more. It is its own key beside the bundle's line, whichtest_gate_loop_contributedholds verbatim:test_the_package_data_ships_the_record_and_every_copylands with it. It holds the table to the record: the patterns underopendox.contractsship exactly the record and every copy the record pins. Atca52182, where the line landed (the test is unchanged since):KeyError: 'opendox.contracts').schemas/, it fails, because the patterns would ship a file the record does not pin.The test parses
pyproject.tomland builds no wheel. The install-level evidence is real wheels: each built withpip wheel . --no-depsfrom a clean clone, installed into a separate venv and probed from outside any source tree. The two module digests are the head's own files. The probe'sopendox from:lines are left out; each showed the run importing from its venv's site-packages.Compared with a wheel built without the line, it adds exactly those five data files and removes nothing: 106 entries outside
dist-infobecome 111, and the 41opendox/web/entries are unchanged. Without the line, an installed validator refused. That was measured at69f2040, before the line landed:Copilot's two findings, at
97b314aand again at69f2040, were this line. Both threads were answered twice, with the held measurement and then withca52182, and both are resolved.The fail-closed build (Copilot's findings at
ca52182,bc3470b,2b32cbf,9d2cda1and4474514)The module promises that a copy it cannot evaluate is refused when its validator is built, as
SchemaNotEvaluable(aValidatorUnavailable). Copilot's review atca52182found a hole in that promise, in code unchanged sinced89f252a:type: {}madeset(names)raiseTypeErrorduring the build. I probed the same class with 40 malformed values. Atca52182:type: {},type: [["string"]],format: {},allOf: 5, and two non-text unknown keys (a mixed sort). A pattern with an unbounded repetition crashed it too (OverflowError).uniqueItems: "yes"read as true,maxLength: -1refused every string, andmaximum: nanpassed everything.bc3470b's commit message says twenty-three of them built. The probe's count is thirty.At
80b5f9eall 40 are refused, and so are these:_SHAPES). A test holds_SHAPEStoKEYWORDS, so no evaluated keyword goes unchecked.enum.$idor$schemabelow the root) is refused, since it would move where its references resolve.-1,01) names nothing. Python'sint()would read another element.bc3470b: a reference cycle that never moves into the instance, such as$ref: "#"or two$defsthat refer to each other, is refused, naming the cycle. jsonschema 4.26.0 recurses on those until Python's limit. A recursive schema that moves into the instance before it recurs, such as a tree whose children are items, is still evaluated. A copy nested deeper than Python's recursion limit is refused too.2b32cbf: a JSON-pointer token whose~is not~0or~1makes no pointer (RFC 6901). A$refcarrying%is refused as a percent-encoded fragment this module does not decode. At2b32cbf,#/$defs/a%20bread the literal keya%20band passed5, where jsonschema decodes it toa band fails5. The same review's record finding is under the input set above.9d2cda1: the record's key refusal ransorted()over a record's keys, so a key that is not text raisedTypeError. It now orders keys by theirrepr. An instance fuzz of the same class found worse at evaluation: underadditionalProperties: false, the report ransorted()over an instance's extra keys, and 115 of 3,000 odd instances crashed a validator. Those keys are ordered byreprtoo. YAML nested past Python's recursion limit escaped the three YAML reads (the record's,contracts.load()andvalidator_for()) asRecursionError, and each now refuses it.4474514: a bound past a float's range, such as a 400-digit YAML integer, crashed the build inmath.isfinite(), and every int is now finite. Aconstorenumthat contains itself is refused. On the instance side, equality was judged on nested tuples, which CPython compares recursively, so an instance value 5000 deep crashed its judgement. The canon is now text, built without recursing and compared as text, and it keeps JSON equality. A violation's detail always shows something, and PyYAML'sValueError(an integer past 4300 digits, an impossible date) is refused by all three YAML reads.bc3470b's review also found thatenum: []andrequired: []should be refused. Those two findings are not taken. Both are valid 2020-12 schemas. The metaschema givesenumnominItemsand givesrequireddefault: []; theminItems: 1was draft-04's. jsonschema treats both as this validator does, and their threads carry the evidence.The four copies use none of the refused forms: they have no anchors or aliases, no nested
$idor$schema, only integer counts, and references only into$defs. All six kinds build as before. Of the 58 new cases through2b32cbf, 57 fail againstca52182's validator and pass here. The 58th, the recursive tree, passes on both, as the guard against over-refusing.9d2cda1's four new refusal cases fail against2b32cbf's code, and its escape case passes on both, as the same kind of guard.4474514's four new cases fail against9d2cda1's code.80b5f9e's six new refusal and depth cases fail against4474514's code. Its canon-equality case passes on both, as the guard that JSON equality is unchanged.After T049: Copilot at
27bcefc0, one thread, taken inbf51a30aRecursionError. A recursive schema that moves into the instance (a tree whose children are items of the node) recurs once per level of the instance, and the build accepts such a schema. Measured at27bcefc0with this module's own tree schema, at the default limit of 1000: 100 levels judged, and 200, 300, 400, 1000 and 5000 levels raised.KindValidator.iter_errors()catches theRecursionErrorfrom the walk, once the walk's frames have unwound, and yields one violation,[evaluation-depth] <root>: …. Its rule,DEPTH_RULE, is exported. So the instance is never valid, which fails closed.violations(),is_valid()andvalidate()all go through it.test_an_instance_deeper_than_the_walk_is_judged_not_crashed_on.("required", "/children/0")and the depth violation are named.27bcefc0's validator the case raisesRecursionError. A mutant that swallows the error silently fails it too.bf51a30a:selected=2812 passed=2801 skipped=11, which is +1. F4.1 reads 19.Copilot at
bf51a30a("Needs a closer look", no thread): taken in6b68e32a_is_bound()admits an integer of any size, so a schema withminimum: 10**5000builds. But eight details (minimum,maximum,minLength,maxLength,minItems,maxItems,minProperties,maxProperties) put the bound into the text directly. So validating0against that schema raisedValueError, Python's 4300-digit limit on int-to-text, while the violation was being written.minimum,maximum(at-10**5000),minLength,minItemsandminProperties. The three max counts cannot fire at such a size._brief(), as the instance's value already was, so a huge one reads<an int of 16610 bits>. An ordinary bound reads exactly as before, since_brief(5)is"5".test_a_violations_detail_shows_a_bound_of_any_size, five cases, which all fail againstbf51a30a's validator, plus an ordinary-bound check.6b68e32a:validateand SonarCloud are green, withselected=2817 passed=2806 skipped=11 failures=0 errors=0, which is +5.Copilot at
6b68e32a: "Needs a closer look", no findings, 0 threadsaudit_refwhenever theprovider_retryobject is present." It is not taken, on the holder's ruling (2026-09-29), and answered in this comment. The note opened no thread.provider_retryis inxfactory-workbench-chat-turn.schema.yaml, one of the four packaged copies. That copy must stay byte for byte the spec leg's file, now the file atf7ee3c76. Its sha256 (350bfedc…) is the onecopies.yamland the root's manifest record. Requiringaudit_refin the copy alone would break the falsifier this PR exists for.audit_refis optional by the contract (required: [retried, at_most_once]), andserve_wire.pywrites it only when it has one. Making it required is a contract change for the spec leg and its owner, not for T057.After the root step:
e94ab323f7ee3c76, a squash of § 3.4 S6: /source becomes openDox's own fixed core arm (RULED Q4) #16 whose tree iscd49eb25's (7b00d19e). dox-v1.1: T053's neutral snapshot contract (spec pin, manifest bump, CHANGELOG draft) openDox#14 moved the root's spec pin to it. The root'sspecgitlink andcontracts/spec-pin.yamlnamef7ee3c76, and itscontracts/manifest.yamlrecords the four files at that commit, with the four digests above.e94ab323movescopies.yaml'scommitand the falsifier'sSPEC_COMMITfromcd49eb25tof7ee3c76, together, on the holder's word. No digest moves. Each of the four copies iscmp-identical to the file atf7ee3c76. The comments that say what the root pins now say it, andtests/test_validator.py's corpus note names the landed commit.SPEC_COMMITback alone fails 2 of the falsifier's 28 cases, so the two still move together or not at all.e94ab323:selected=2817 passed=2806 skipped=11, unchanged.After #57 landed:
1433234,cb40b977and three review rounds1433234merges T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's final headd7954fc6, with no conflict. T057's own diff against it is byte-identical to its diff against8e7da4a2: 55 files, +6320.cb40b977mergesmaina691e4e4, whose tree isd7954fc6's, so it adds no content.cb40b977: 2 threads.3351f6a7: YAML builds values JSON has not (sets, bytes, dates, non-text keys, non-finite numbers), andconst/enumwere checked only for self-containment._first_non_json()now refuses them at build.test_a_const_or_enum_that_holds_a_value_json_has_not_is_refused, 6 cases, red againstcb40b977.water, so its count of 4 is right, the validator finds no violation there, and the file is byte for byte openDox-spec's atf7ee3c76.2b8ad245: a packaged record or copy that is present but unreadable raisedPermissionErrorout ofvalidator_for(). EveryOSErrorin_read_package_fileis now aCopyRefused, and a missing file keeps its message.test_a_present_file_that_cannot_be_read_is_refused_not_raised, over the record and the snapshot copy at mode 000 (skipped as root), red against3351f6a7.2b8ad245: 3 threads.753ffa19: an instance key of10**5000has no decimal text, so a pointer beneath it, and the unexpected-properties report, raisedValueError. Pointer parts fall back to_shown, and extra keys are ordered and shown item by item (_brief_items).test_an_instance_key_of_any_size_is_named_not_crashed_on, red against2b8ad245.enumanduniqueItemsjudge it without crashing.test_a_non_text_key_that_is_not_a_scalar_is_judged_as_itselfguards it.753ffa19: "Needs a closer look", no findings, 0 threads. CI is green, withselected=2850 passed=2839 skipped=11 failures=0 errors=0.7.1a, measured
The consumer's
scripts/validate-ideation-dashboard-contracts.py, run at openXdox-code4610bca5:The docstring's six reasons are: it finds its schemas from its own position; it names ten schemas of three owners; it is the consumer's file; it needs
jsonschema,referencingandrfc3339-validator; it knows noopendox-snapshot; and it is a subprocess script, where openDox-code has noscripts/.Verification
80b5f9e:validate(run36471401716) and SonarCloud are green.CI=true,LANG=C.UTF-8, PostgreSQL 16,-e ".[runtime,test]"), at the committed head with a clean tree. The base is T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's head,5a6fe637: 2578 passed, 11 skipped. At80b5f9eit is 2791 passed, 11 skipped. At27bcefc, after T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's head8e7da4a2was merged in, it is 2800 passed, 11 skipped (selected=2811). That is T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57's own 2598 plus these 213.--noconftestpasses too (213).97b314aon one case: the committed-manifest guard, over the two workbench examples then carried (above). My earlier local whole-suite run had passed because it ran before those files were committed, so these runs are at the committed head.LANG=C.UTF-8is set because CI's runner sets it. In a shell with no locale,test_model_provider_broker::test_the_broker_child_inherits_no_credential_shaped_environmentfails atca52182and here alike, because Python's C-locale coercion (PEP 538) putsLC_CTYPEin the broker child's environment. That is the shell, not this PR.80b5f9e's committed source.cd49eb25, all kinds, positives and negatives: 0 disagreements.tests/test_opendox_snapshot_contract.pyevaluator's (rule, place) exactly, and equal jsonschema's read througherror.schema'sx-rule.UnknownKind.rfc3339-validatoranchors with$, so it admits...Zfollowed by a newline, and this validator refuses it. That divergence is documented in_is_date_time, and the snapshot contract's own patterns guard the same tail.9d2cda1.For the later tasks
opendox.validator.validate(snapshot, kind=<the generator's declared contract>), and printvalidator.report(...)on stderr;ValidatorUnavailableis the "could not run" case, which--strictmakes fatal;[title-and-summary-are-text] /documents/1/title;tests/test_neutral_projection.pyshould read this packaged copy (opendox.contracts) in place of T054'stests/fixtures/opendox-snapshot.schema.yaml.validate_manifestforideation-workbench):new_candidatesdisjoint from members ∪ excluded), nor its committed-manifest guard. So routingvalidate_manifesthere drops those rules unless T055 carries them.test_every_manifest_openDox_own_workbench_writes_validatesholds the manifestsworkbench.Workbenchwrites to pass here: every seed kind, every member route and every action, with an exclusion and a notebook binding.opendox.validator.validatorsatserve_wire.register_doxbench_validators. The semantic rules of the two wire kinds are still T085's.contracts/manifest.yamlsays the code leg "carries nocontracts/path at all", which is no longer true ofsrc/opendox/contracts/;make pinscould gain that check.Before READY (not this PR's act)
T049 lands.Done: openxFactory#1204 →9d2e5bc3.T053 lands, and its root step moves the openDox root's spec pin. ThenDone: T053 →copies.yaml'scommitand the test'sSPEC_COMMITmove to that commit together.f7ee3c76, dox-v1.1: T053's neutral snapshot contract (spec pin, manifest bump, CHANGELOG draft) openDox#14 →52005213, ande94ab323moves the two together, with no digest moved.T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57 (T054) lands, with this PR retargeted toDone: retargeted, T054, openDox's small neutral projection (5.1-5.3) (plan 034) #57 →mainfirst.a691e4e4, andmainmerged in ascb40b977.Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
🤖 Generated with Claude Code